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 and now need to control or explain the dates it produces.
How the scheduler thinks#
Every task has a preferred position: the dates you sent in TaskData. Scheduling starts from those dates and applies three kinds of rules on top:
- Links push or pull a task relative to another task. A
LinkDataconnects asourceto atargetwith one of four link types and an optional lag. 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 whenapplyConstraintsistrue. - Gap behaviour decides what to do with free time. With
GapBehavior.PRESERVE(the default), a task keeps its preferred dates whenever its links allow them. WithGapBehavior.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. moveProjects picks the mode.
Some tasks never move. They act as fixed points for everything linked to them:
- tasks with
autoScheduling = false - unscheduled tasks (
unscheduled = true, which also drops their links) - completed tasks, when
useProgressis on andprogress == 1.0
Nothing in this guide throws at scheduling time. A constraint the links can't satisfy becomes a Conflict. A link that can't apply becomes a Note.DroppedLink. A dependency cycle becomes a Cycle entry. All three are data in the ScheduleResult. Exceptions come only from invalid input, such as a dated constraint without a date or a non-finite lag: strict validation throws an InputException. See Reading results for the full result shape.
To see which rule placed a task, call solve with SolveOptions#withReasons(true). Each update then carries a Reason: Reason.Asap (pushed by a predecessor link), Reason.Alap (pulled by a successor link), Reason.SummaryTranslation (moved with its summary block), Reason.Constraint or Reason.Preferred.
The examples below use Engine.schedule, a one-shot new Project().solve(data, options). The same rules apply to a long-lived Project.
Link types and lag#
A link's type names which end of the source drives which end of the target:
LinkType |
The target's... | ...follows the source's... |
|---|---|---|
FS (default) |
start | finish |
SS |
start | start |
FF |
finish | finish |
SF |
finish | start |
lag adds working time between the two ends. It's counted on the target task's calendar, in durationUnit unless the link sets its own lagUnit. See Working time and calendars for how calendars define working time.
Chaining tasks with lag#
Build each link with new LinkData() and the withX setters. Fields you don't set keep their defaults: FS, zero lag, no lagUnit.
;
;
Output:
task 2: 2026-03-09 00:00 .. 2026-03-11 00:00
task 3: 2026-03-03 00:00 .. 2026-03-05 00:00
Task 1 works Monday to Wednesday. 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.
Lag in a different unit#
A project measured in days can still have a link whose lag is in hours. Set lagUnit on that link:
;
;
Output:
strip: 2026-03-03 12:00 .. 2026-03-04 12:00
strip starts four working hours after pour ends on Monday evening, on the default 08:00-12:00, 13:00-17:00 calendar.
Without a lagUnit, lag is measured in the project's durationUnit.
Links that get dropped#
A link that can't apply is skipped and reported as a Note.DroppedLink with a 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
descendantLinksisfalse(the default) - an endpoint is a summary with
autoScheduling = falseand summaries expand to leaves (see Summary tasks)
A lag that isn't a finite number is different: strict validation throws an InputException with path links[i].lag.
Constraints#
A constraint is set per task with constraintType and, for the six dated types, constraintDate:
ConstraintType |
Meaning | Needs a 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 applyConstraints is true. With the default false, the scheduler ignores constraintType and constraintDate completely, so even an MSO pin has no effect. A dated constraint without constraintDate is invalid input in strict mode, whatever applyConstraints says.
Holding a task back with a dated constraint#
;
;
Output:
install: 2026-03-05 00:00 .. 2026-03-07 00:00 (Constraint{})
install moves from Monday to Thursday with Reason.Constraint. The end is Saturday 00:00, which is the close of Friday in day units.
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 Conflict.ConstraintViolation:
;
;
Output:
demo: MSO LATEST_START, required 2026-03-03 00:00, actual 2026-03-05 00:00
build finishes on Wednesday, so the link wants demo to start on Thursday. The MSO pin keeps it on Tuesday, and updates is empty. The Violation says which bound broke: here the latest allowed start.
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:
;
;
Output:
order-food: 2026-03-05 00:00 .. 2026-03-06 00:00
order-food moves from Monday to Thursday, the last day before party. To plan a whole project backward from a deadline, set scheduleFromEnd and projectEnd instead.
Inheriting a summary's constraint#
With projectConstraint on, a task without its own constraint inherits the nearest ancestor's:
;
;
Output:
step (TASK): 2026-03-04 00:00 .. 2026-03-05 00:00
phase (SUMMARY): 2026-03-04 00:00 .. 2026-03-05 00:00
step moves to Wednesday, and the rolled-up phase follows it as a MutationKind.SUMMARY update. With projectConstraint off, nothing in this example moves. groupMode turns inheritance off again. That's the only thing groupMode does.
Gap behaviour#
gapBehavior decides what happens to the free time between linked tasks:
GapBehavior.PRESERVE(default) keeps each task at its preferred dates as long as its links allow them. A link pushes a task only when the task would otherwise start too early.GapBehavior.COMPRESSpulls 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:
;
;
Output:
PRESERVE design: starts 2026-03-02 00:00
PRESERVE review: starts 2026-03-05 00:00
COMPRESS design: starts 2026-03-02 00:00
COMPRESS review: starts 2026-03-03 00: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 (TaskType.SUMMARY, or "project" in JSON) groups child tasks through their parent field. Its span is always rolled up from its children. Changed summaries appear in updates with kind == MutationKind.SUMMARY. Write them back to your model like any other update.
moveProjects picks one of two modes for links that touch a summary:
- Block mode (
moveProjects = true, the default, together withGapBehavior.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 (
moveProjects = false, or any project withGapBehavior.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 autoScheduling = false, and, with useProgress, a completed child. A summary with autoScheduling = false is a fixed boundary that keeps its explicit dates. In leaf-expansion mode its links are dropped with a Note.DroppedLink.
Moving a summary as a block#
;
;
Output:
moveProjects = true
a TASK: 2026-03-05 00:00 .. 2026-03-06 00:00 SummaryTranslation{summaryId=phase}
b TASK: 2026-03-06 00:00 .. 2026-03-07 00:00 SummaryTranslation{summaryId=phase}
phase SUMMARY: 2026-03-05 00:00 .. 2026-03-07 00:00 Asap{linkId=1, sourceId=prep}
moveProjects = false
phase SUMMARY: 2026-03-05 00:00 .. 2026-03-06 00:00 Preferred{}
a TASK: 2026-03-05 00:00 .. 2026-03-06 00:00 Asap{linkId=1, sourceId=prep}
b TASK: 2026-03-05 00:00 .. 2026-03-06 00:00 Asap{linkId=1, sourceId=prep}
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. In leaf-expansion mode the link lands on each leaf: both a and b start on Thursday with Reason.Asap, and phase shrinks to one day.
Keeping completed work in place#
With useProgress, a child with progress == 1.0 is completed and doesn't move with its block:
;
;
Output:
open: 2026-03-06 00:00 .. 2026-03-07 00:00
phase: null .. 2026-03-07 00:00
Only open moves, to Friday. done stays on Monday, so it isn't listed, and the summary stretches to cover both. Its start didn't change, which is why phase reports only a new end: a Mutation sets the changed fields and leaves the rest null.
Linking a summary to its own children#
By default, a link between a summary and one of its descendants is dropped:
;
;
Output:
link l1 dropped: link between a summary and its own descendant (descendant_links disabled)
Set descendantLinks 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
cyclesas aCyclewith itstasksandlinks - returns analytics with status
AnalyticsStatus.INVALIDif you asked for any (seeAnalytics) - still installs a baseline on a
Project, 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 cycle in the parent hierarchy is different: it's invalid input, and validation throws an InputException.
Reporting cycles#
;
;
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 Project#apply, then solve the current state again with solve(null, ...):
;
;
Output:
cycles: 0
b starts 2026-03-03 00:00
After ba is removed, the project is acyclic again and b moves to Tuesday, after a. 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#
LinkDataandLinkTypeTaskDataforConstraintType, andConflictSchedulingConfigforapplyConstraints,gapBehavior,moveProjects,projectConstraint,descendantLinksanduseProgress- Critical path and slack