ADR 014 Scheduling Engine Architecture - steilerDev/cornerstone GitHub Wiki
ADR-014: Scheduling Engine Architecture
Status
Accepted. Amended 2026-10 (#2199): planned vs forecast dates.
Context
EPIC-06 requires automatic scheduling that respects task dependencies, date constraints, and lead/lag times. When a homeowner adjusts a task's dates or a delay occurs, downstream dependent tasks should be reschedulable. The system must also identify the critical path -- the sequence of tasks that determines the minimum project duration.
Key requirements from the REQUIREMENTS.md:
- "Automatic scheduling based on dependencies" (Section 2.4)
- "Cascade updates when dates change" (Section 2.4)
- "See the critical path so I know which tasks cannot be delayed" (Section 4, User Stories)
- "Automatic rescheduling when a task is delayed" (Section 4, User Stories)
Constraints
- Small dataset: 50--200 work items typical for a home build project
- Single-user interaction model: Only 1--5 users, so concurrent scheduling conflicts are unlikely
- SQLite backend: All data in a single SQLite database; no distributed transaction concerns
- On-demand, not automatic: Scheduling should not run silently in the background. The user explicitly triggers it (clicks an "Auto-schedule" button), so they understand and can review the impact before saving.
Alternatives Considered
| Approach | Description | Pros | Cons |
|---|---|---|---|
| Client-side scheduling | Run the algorithm in the browser | No server round-trip; instant feedback | Risks data inconsistency if browser state diverges from DB; complex to implement in React; harder to test |
| Server-side on-demand | POST endpoint runs scheduling, returns proposed changes | Data integrity guaranteed; single source of truth; easy to test; user reviews before committing | Requires server round-trip; slight latency (acceptable for 50--200 items) |
| Server-side automatic | Scheduling runs automatically on every work item save | Always up-to-date | Unexpected date changes surprise users; harder to debug; performance concern if triggered on every edit |
Decision
Implement a server-side, on-demand scheduling engine using the Critical Path Method (CPM) algorithm.
Algorithm
The CPM algorithm operates in phases:
- Build dependency graph from the
work_item_dependenciestable, includinglead_lag_daysoffsets. - Topological sort using Kahn's algorithm to detect circular dependencies. If a cycle is found, the scheduling request fails with an error identifying the cycle.
- Forward pass: Traverse items in topological order, computing the earliest start (ES) and earliest finish (EF) for each item.
- For items with no predecessors: ES =
start_afterconstraint (if set), otherwise the current date. - For items with predecessors: ES = max(all predecessor-derived dates + lead/lag) and must also respect
start_after. - EF = ES +
duration_days(if duration is set).
- For items with no predecessors: ES =
- Backward pass: Traverse items in reverse topological order, computing the latest start (LS) and latest finish (LF) for each item.
- For items with no successors: LF = EF (the latest finish equals the earliest finish for terminal items).
- For items with successors: LF = min(all successor-derived dates - lead/lag).
- LS = LF -
duration_days.
- Calculate float (slack): For each item, total float = LS - ES (or equivalently LF - EF).
- Identify critical path: Items with zero float form the critical path. These tasks cannot be delayed without extending the overall project duration.
Dependency Type Handling
Each dependency type determines how predecessor and successor dates relate:
| Type | Abbreviation | Forward Pass Rule | Backward Pass Rule |
|---|---|---|---|
| Finish-to-Start | FS | Successor ES >= Predecessor EF + lead/lag | Predecessor LF <= Successor LS - lead/lag |
| Start-to-Start | SS | Successor ES >= Predecessor ES + lead/lag | Predecessor LS <= Successor LS - lead/lag |
| Finish-to-Finish | FF | Successor EF >= Predecessor EF + lead/lag | Predecessor LF <= Successor LF - lead/lag |
| Start-to-Finish | SF | Successor EF >= Predecessor ES + lead/lag | Predecessor LS <= Successor LF - lead/lag |
Lead/Lag Days
The lead_lag_days column on work_item_dependencies adds an offset to the dependency relationship:
- Positive values (lag): Adds waiting time. A FS dependency with
lead_lag_days = 3means the successor starts 3 days after the predecessor finishes. - Negative values (lead): Allows overlap. A FS dependency with
lead_lag_days = -2means the successor can start 2 days before the predecessor finishes.
Scheduling Modes
The API endpoint (POST /api/schedule) supports two modes:
| Mode | Description | Use Case |
|---|---|---|
full |
Schedules all work items from scratch based on dependencies, constraints, and the current date | Initial planning; major replanning after scope changes |
cascade |
Starting from an anchor work item, propagates date changes only to downstream successors | A single task was delayed; user wants to see the ripple effect |
Constraints Respected
| Constraint | Type | Behavior |
|---|---|---|
start_after |
Hard | Item cannot start before this date. The scheduling engine sets ES = max(dependency-derived ES, start_after). |
start_before |
Soft | Item should start before this date. If the scheduled start exceeds start_before, a warning is included in the response but the schedule is not rejected. |
duration_days |
Calculation | Used to compute end date from start date (EF = ES + duration_days). Items without duration_days are scheduled as zero-duration (milestones or unestimated items). |
Response Shape
The scheduling endpoint is read-only — it returns the computed schedule without persisting any changes to the database. The client displays results for user review, then applies accepted changes via individual PATCH /api/work-items/:id calls.
interface ScheduleResponse {
scheduledItems: ScheduledItem[];
criticalPath: string[]; // Array of work item IDs on the critical path
warnings: ScheduleWarning[];
}
interface ScheduledItem {
workItemId: string;
previousStartDate: string | null;
previousEndDate: string | null;
scheduledStartDate: string; // Earliest start (ES)
scheduledEndDate: string; // Earliest finish (EF)
latestStartDate: string; // Latest start (LS)
latestFinishDate: string; // Latest finish (LF)
totalFloat: number; // Days of slack: LS - ES
isCritical: boolean;
}
interface ScheduleWarning {
workItemId: string;
type: 'start_before_violated' | 'no_duration' | 'already_completed';
message: string;
}
The client receives the computed schedule and can display proposed changes for user review before applying them.
Interface Contract (for Backend Implementation)
The scheduling engine is a service module with this interface:
interface SchedulingEngine {
schedule(params: {
mode: 'full' | 'cascade';
anchorWorkItemId?: string; // Required for cascade mode
}): ScheduleResult;
}
interface ScheduleResult {
scheduledItems: ScheduledItem[];
criticalPath: string[];
warnings: ScheduleWarning[];
}
The service reads all work items and dependencies from the database, runs the algorithm in memory, and returns the proposed schedule. It does not persist any changes -- the caller (route handler) decides whether to apply the results.
Consequences
What becomes easier
- Data integrity: Server-side scheduling with SQLite transactions ensures the dependency graph and dates are always consistent.
- Testability: The scheduling algorithm is a pure function (input: work items + dependencies; output: proposed schedule). It can be unit-tested without HTTP or database setup by passing in mock data.
- User control: On-demand scheduling means users always see and approve changes before they are applied. No surprises.
- Critical path visibility: The CPM algorithm naturally identifies the critical path as a byproduct of the forward/backward passes. No additional computation needed.
- Cascade mode efficiency: For single-task delays, only downstream items are recalculated, not the entire project.
What becomes harder
- Two-step user interaction: The user must trigger scheduling, review the proposed changes, and then accept them. This adds UI complexity compared to automatic rescheduling.
- No real-time updates: If one user triggers scheduling while another is editing dates, the second user's changes may conflict with the proposed schedule. However, with <5 users this is an acceptable trade-off.
- Algorithm complexity: Implementing CPM with four dependency types and lead/lag days is moderately complex. However, it is a well-documented algorithm with established reference implementations.
Future Extensibility
- Calendar integration: The scheduling engine could later account for non-working days (weekends, holidays) by adjusting duration calculations. This is not needed for the initial implementation.
- Resource leveling: If resource constraints are added in the future, the engine interface can be extended without changing the API contract.
- What-if analysis: The read-only nature of the scheduling response naturally supports scenario planning (run scheduling with different parameters without committing changes).
Amendment (#2199): planned vs forecast dates
- Planned = stored.
autoReschedulepersists the CPM result without the today floor (Rules 2/3).work_items.start_date/end_dateare the planned dates. Roots keep their stored start. Undated tasks (not completed, no predecessor,start_dateNULL) are never written (contract 4 point 4). - One projection function.
projectSchedule(graph, today)(schedulingEngine.ts) runs the pure engine twice on identical inputs, once withapplyTodayFloor: false(planned) and once withtrue(forecast). Any difference comes from the floor. The timeline, work-item list/detail, dependency and milestone summaries, the calendar feed, milestone-delay events and household-item delivery dates all read it. - Flags.
isLate: the floor moved the item itself.isHeldUp: forecast differs from planned and the item is not late itself (a task waiting on a late task is Held up, not Late).lateDays>= 1 only when late. Undated tasks get forecast dates and no flags. - Milestones get
projectedDate(latest forecast end of contributors),isLate/lateDaysandisEarly/earlyDaysfrom the same projection. Milestone-delay events come from it (ADR-040 W2). - Household items keep persisted
target_delivery_date/is_latewith the floor, fed from the forecast run.
Deviation Log
| Date | Section | Deviation | Resolution |
|---|---|---|---|
| 2026-10-08 | Decision (read-only endpoint) | The ADR describes scheduling only as the read-only POST /api/schedule. The code also has autoReschedule (server/src/services/schedulingEngine.ts), called from the dependency, work-item and household-item services after writes, which persists the CPM dates — including the Rule 2/3 "today floor" — into work_items.start_date / end_date, and calls onMilestoneDelayed on every run while a milestone's projected end exceeds its target. Consequences: a late task's planned date is overwritten (D-15), lists and the timeline can show different dates (D-38), and delay events repeat (ADR-040). |
ADR was incomplete; the behaviour is intended to change. Planned — EPIC-21 (UX restructure) story 0.8, contract 4 in API Contract › EPIC-21: autoReschedule persists dates without the today floor, and the floor becomes a read-time projection (projectedStartDate, projectedEndDate, isLate). This ADR gets its amendment section when story 0.8 lands. |
| 2026-10-10 | Decision (read-only endpoint); Amendment | Resolves the 2026-10-08 row. autoReschedule no longer persists the Rule 2/3 floor, the floor is a read-time projection (projectSchedule), and milestone-delay events are de-duplicated (ADR-040 W2). |
Implemented in #2199; see the Amendment section above and contract 4 in the API Contract. |