Links, constraints and summaries#
This guide covers the rules that decide where a task lands: dependency links with lag, date constraints, how much free time the scheduler keeps between tasks, how summary tasks move, and what happens when links form a cycle. It's for developers who already schedule a project through the raw exports and now need to control or explain the dates it produces.
How the scheduler thinks#
Every task has a preferred position: the dates you send in its TaskData record. Scheduling starts from those dates and applies three kinds of rules on top:
- Links push or pull a task relative to another task. A
LinkDatarecord inlinksconnects asourceto atargetwith one of four types and an optionallag. Links are the only thing that ties one task's dates to another's. - Constraints bound a single task to a date (
"snet","mfo", ...) or ask it to sit as early or as late as its links allow ("asap","alap"). They act only whenconfig.apply_constraintsistrue. - Gap behaviour decides what to do with free time. With
"preserve"(the default), a task keeps its preferred dates whenever its links allow them. With"compress", every task is pulled to its earliest possible position.
Summary tasks sit on top of this. A summary's span is always derived from its children, so a summary takes both dates or none, never a duration. When a link points at a summary, the scheduler either moves the whole summary as one block or expands the link onto the summary's leaves. config.move_projects picks the mode.
Some tasks never move. They act as fixed points for everything linked to them:
- tasks with
auto_scheduling: false - unscheduled tasks (
unscheduled: true, which also drops their links) - completed tasks, when
config.use_progressistrueandprogressis1
Nothing in this guide produces an error envelope at scheduling time. A constraint the links can't satisfy becomes an entry in conflicts. A link that can't apply becomes a dropped-link entry in notes. A dependency cycle becomes an entry in cycles. All three come back in an ok: true ScheduleResult. Error envelopes come only from invalid input, such as a dated constraint without a date. See Reading results for the full result shape.
To see which rule placed a task, send {"reasons": true} as the options. Each update then carries a reason whose kind is "asap" (pushed by a predecessor link), "alap" (pulled by a successor link), "summary-translation" (moved with its summary block), "constraint" or "preferred".
The examples below run in Node.js against the pkg-node/ build and call the stateless schedule export unless they need a project handle. 2026-08-10 is a Monday, and the default calendar works Monday to Friday, 08:00-12:00 and 13:00-17:00.
Link types and lag#
A link's type names which end of the source drives which end of the target:
type |
The target's... | ...follows the source's... |
|---|---|---|
"fs" (default) |
start | finish |
"ss" |
start | start |
"ff" |
finish | finish |
"sf" |
finish | start |
The type string is case-insensitive, and the numbers 0 to 3 (or "0" to "3") are accepted in the same order. See Link type for the full list.
lag adds working time between the two ends. It's counted on the target task's calendar, in config.duration_unit unless the link sets its own lag_unit. See Working time and calendars for how calendars define working time.
Chaining tasks with lag#
const = ;
const = ;
const = ;
throw new;
Output:
task 2: 2026-08-17 08:00 -> 2026-08-19 08:00
task 3: 2026-08-11 08:00 -> 2026-08-13 08:00
Task 1 works Monday to Wednesday and ends on Thursday at 08:00. The FS lag of two working days covers Thursday and Friday, so task 2 starts the next Monday. Task 1 isn't listed because it didn't move: updates holds only changed tasks.
Waiting for several predecessors#
A task with more than one incoming link starts when the latest of them allows:
const = ;
const = ;
const = ;
throw new;
Output:
task 3: 2026-08-17 08:00 -> 2026-08-18 08:00
Task 1 would let task 3 start on Wednesday, but the five-day task 2 doesn't finish until the next Monday. The later bound wins.
Lag in a different unit#
A project measured in days can still have a link whose lag is in hours. Set lag_unit on that link:
const = ;
const = ;
const = ;
throw new;
Output:
strip: 2026-08-11 12:00 -> 2026-08-12 12:00
pour works all of Monday. Four working hours after that, on the default calendar, is Tuesday at 12:00.
A link without lag_unit measures lag in config.duration_unit.
Links that get dropped#
A link that can't apply is skipped and reported in notes as {"kind": "dropped-link", "link": ..., "reason": ...}. The rest of the plan still runs. This happens when:
- the source or target id doesn't exist, or names an unscheduled task
- the link connects a summary to its own descendant and
config.descendant_linksisfalse(the default) - an endpoint is a summary with
auto_scheduling: falseand summaries expand to leaves (see Summary tasks)
A link to a task id that doesn't exist comes back like this:
lag must be a JSON number or null. A string such as "2" fails the whole call with a parse error. JSON can't carry NaN or Infinity: JSON.stringify turns them into null, which reads as the default lag of 0. Check that lag values are finite before you serialize them.
Constraints#
A constraint is set per task with constraint_type and, for the six dated types, constraint_date:
constraint_type |
Meaning | Needs constraint_date |
|---|---|---|
"asap" |
as soon as the links allow | no |
"alap" |
as late as the links allow | no |
"snet" / "snlt" |
start no earlier / no later than | yes |
"fnet" / "fnlt" |
finish no earlier / no later than | yes |
"mso" / "mfo" |
must start on / must finish on | yes |
Constraints are hard bounds, and they exist only when config.apply_constraints is true. With the default false, the scheduler ignores constraint_type and constraint_date completely, so even an "mso" pin has no effect.
A dated constraint without constraint_date is invalid input whatever apply_constraints says. In strict validation the call returns an error envelope:
Holding a task back with a dated constraint#
const = ;
const = ;
const = ;
const = ;
throw new;
Output:
install: 2026-08-13 08:00 -> 2026-08-17 08:00 (constraint)
install moves from Monday to Thursday. It works Thursday and Friday, so its end is the start of the next working day, Monday at 08:00.
Detecting a constraint that a link breaks#
When a link and a hard constraint disagree, the constraint wins. The task keeps the constrained date, and the result reports the clash as a constraint-violation conflict:
const = ;
const = ;
const = ;
throw new;
Output:
demo: mso latest-start, required 2026-08-11 08:00, actual 2026-08-13 08:00
build finishes on Thursday at 08:00, so the link wants demo to start then. The "mso" pin keeps it on Tuesday, updates is empty, and violation says which bound broke: here the latest allowed start. required is the constraint's bound, and actual is the date the link asked for.
Placing a task as late as possible#
"alap" moves a task as close to its successors as the links allow, even in a forward-scheduled project:
const = ;
const = ;
const = ;
throw new;
Output:
order-food: 2026-08-13 08:00 -> 2026-08-14 08:00
order-food moves from Monday to Thursday, the last working day before party. To plan a whole project backward from a deadline, set config.schedule_from_end and config.project_end instead. See SchedulingConfig.
Inheriting a summary's constraint#
With config.project_constraint set to true, a task without its own constraint inherits the nearest ancestor's:
const = ;
const = ;
const = ;
throw new;
Output:
step (task): 2026-08-12 08:00 -> 2026-08-13 08:00
phase (summary): 2026-08-12 08:00 -> 2026-08-13 08:00
step moves to Wednesday, and the rolled-up phase follows it as an update with kind: "summary". With project_constraint: false, nothing in this example moves. config.group_mode turns inheritance off again. That's the only thing group_mode does.
Gap behaviour#
config.gap_behavior decides what happens to the free time between linked tasks:
"preserve"(default) keeps each task at its preferred dates as long as its links allow them. A link only pushes a task when the task would otherwise start too early."compress"pulls every task to the earliest position its links and constraints allow, closing the gaps.
Keeping or closing a gap#
The same data, scheduled with both settings:
const = ;
Output:
preserve design: starts 2026-08-10 08:00
preserve review: starts 2026-08-13 08:00
compress design: starts 2026-08-10 08:00
compress review: starts 2026-08-11 08:00
includeUnchanged lists every task, including the ones that didn't move. Under "preserve", review stays on Thursday because the link only requires it to start after Monday. Under "compress", it moves to Tuesday.
"compress" also changes how summaries behave: it always expands summary links onto leaves, as the next section describes.
Summary tasks#
A summary is a task with type: "summary". "project" is accepted as an input alias, and the output always says "summary". A summary groups child tasks through their parent field. Its span is always rolled up from its children, and changed summaries appear in updates with kind: "summary". Write them back to your model like any other update.
config.move_projects picks one of two modes for links that touch a summary:
- Block mode (
move_projects: true, the default, together withgap_behavior: "preserve"): the summary moves as one block. A link into the summary shifts the whole block, and every child keeps its offset inside it. - Leaf expansion (
move_projects: false, or any project withgap_behavior: "compress"): the link is expanded onto the summary's leaf tasks, and each leaf is scheduled on its own.
Inside a moving block, some children stay put: a child pinned by a dated constraint, a child with auto_scheduling: false, and, with config.use_progress, a completed child. A summary with auto_scheduling: false is a fixed boundary that keeps its explicit dates. In leaf-expansion mode its links are dropped with a dropped-link note.
With config.strict_calendar set to true, a child that a moving block lands on non-working time is reported as a calendar-non-working conflict.
Chains of summary-to-summary links in block mode are a known open issue. On large graphs that also have leaf links crossing summary boundaries, dates can grow without bound until the module traps with a RuntimeError. A minimal summary-to-summary link schedules correctly.
Moving a summary as a block#
const = ;
Output:
move_projects: true
a task: 2026-08-13 08:00 -> 2026-08-14 08:00 (summary-translation)
b task: 2026-08-14 08:00 -> 2026-08-17 08:00 (summary-translation)
phase summary: 2026-08-13 08:00 -> 2026-08-17 08:00 (asap)
move_projects: false
phase summary: 2026-08-13 08:00 -> 2026-08-14 08:00 (preferred)
a task: 2026-08-13 08:00 -> 2026-08-14 08:00 (asap)
b task: 2026-08-13 08:00 -> 2026-08-14 08:00 (asap)
The phase summary has no dates of its own, so its span comes from a and b. In block mode, the link pushes phase to Thursday, and a and b move with it, one day apart as before. Their reason is {"kind": "summary-translation", "summaryId": "phase"}. With move_projects: false, the link lands on each leaf: both a and b start on Thursday, pushed by link 1, and phase shrinks to one day.
Keeping completed work in place#
With config.use_progress, a child with progress: 1 is completed and doesn't move with its block:
const = ;
const = ;
const = ;
throw new;
;
The printed updates, one entry per line:
Only open moves, to Friday. done stays on Monday, so the summary keeps its start and only its end and duration change. An update lists only the fields that changed, so phase has no start_date here.
Linking a summary to its own children#
By default, a link between a summary and one of its descendants is dropped:
const = ;
const = ;
const = ;
throw new;
Output:
link l1 dropped: link between a summary and its own descendant (descendant_links disabled)
Set config.descendant_links to true to keep such links.
To reschedule only one summary and the tasks that depend on it, see Reschedule a branch.
Cycles#
A dependency cycle (A waits for B, B waits for A) has no valid schedule. Planning Engine doesn't treat it as an error. A cyclic run:
- leaves every date unchanged and returns empty
updates - lists each unique simple cycle in
result.cycles, each with itstasksandlinks - returns
analyticswithstatus: "invalid"if you asked for any - still installs a baseline on a
JsonProject, so you can edit the data to remove the cycle
Cycle detection covers fixed and completed tasks and summary containment too. A self-link is a one-task cycle: a link from task 1 to task 1 comes back as {"tasks": [1], "links": [1]}. A cycle in the parent hierarchy is different. It's invalid input and returns a validation error envelope.
Reporting cycles#
const = ;
const = ;
const = new;
Output:
updates: 0
cycle: tasks ["a","b"], links ["ab","ba"]
analytics: invalid
Link bc isn't part of the cycle, but c doesn't move either: a cyclic run schedules nothing.
Breaking a cycle#
Remove one of the cycle's links with apply, then call solve without data to schedule the current state:
const = ;
const = ;
const = new;
Output:
removed: ["ba"]
cycles: 0
b starts 2026-08-11 08:00
c starts 2026-08-12 08:00
After ba is removed, the project is acyclic again. b moves to Tuesday, after a, and c follows b. You decide which link to remove: the engine only reports the cycle. For more on apply and partial edits, see Editing a project.
See also#
LinkDataandTaskDataSchedulingConfigforapply_constraints,gap_behavior,move_projects,project_constraint,descendant_links,use_progressandstrict_calendarScheduleResultforconflicts,notes,cyclesandreason- Common types for link types,
ConstraintTypeand gap behavior - Critical path and slack