This guide covers the rules that decide where a task lands: dependency links with lag, date constraints, how much slack 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.
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 LinkData connects a source to a target with one of four 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 when config.apply_constraints is true.
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. With GapBehavior::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, depending on config.move_projects.
Some tasks never move, and 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_progress is on and progress == 1.0
Nothing in this guide raises an error at scheduling time. A constraint the links can't satisfy becomes a Conflict, a link that can't apply becomes a Note::DroppedLink, and a dependency cycle becomes a Cycle entry. All three are data in the ScheduleResult. Errors come only from invalid input, such as a dated constraint without a date or a non-finite lag. See Reading results for the full result shape.
To see which rule placed a task, set SolveOptions.reasons to true. Each update then carries a Reason: Asap (pushed by a predecessor link), Alap (pulled by a successor link), SummaryTranslation (moved with its summary block), Constraint or Preferred.
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.
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.
strip starts at 2026-03-03 12:00: four working hours after pour ends on Monday evening, on the default 08:00-12:00, 13:00-17:00 calendar.
Note
Per-link lag_unit is a Planning Engine extension. dhtmlxGantt always measures lag in config.duration_unit. A lag_unit equal to the project unit behaves exactly like gantt.
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 in strict mode, whatever apply_constraints says. ConstraintType::is_dated tells you which types need the date.
install moves from Monday to Thursday, 2026-03-05 00:00 .. 2026-03-07 00:00, with Reason::Constraint. The end is Saturday 00:00, which is the close of Friday in day units.
When a link and a hard constraint disagree, the constraint wins. The task keeps the constrained date, and the result reports the clash as Conflict::ConstraintViolation:
demo: Mso LatestStart, 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, updates is empty, and the Violation says which bound broke: here the latest allowed start.
order-food moves from Monday to Thursday, 2026-03-05 .. 2026-03-06, the last day before party. For a whole project planned backward from a deadline, set config.schedule_from_end and config.project_end instead. See SchedulingConfig.
step moves to Wednesday, and the rolled-up phase follows it as a MutationKind::Summary update. With project_constraint: false, nothing in this example moves. config.group_mode turns inheritance off again. That's the only thing group_mode does.
Note
dhtmlxGantt's JS engine accepts project_constraint but never applies the inherited constraint to dates. Planning Engine follows the documented intent, so this is one of the three declared date differences from gantt. Leave project_constraint off for gantt's behaviour.
config.gap_behavior 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 only pushes a task when the task would otherwise start too early.
GapBehavior::Compress pulls every task to the earliest position its links and constraints allow, closing the gaps.
include_unchanged 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 described in the next section.
A summary (TaskType::Summary, or "project" on the gantt wire format) groups child tasks through their parent field. Its span is always rolled up from its children, and changed summaries appear in updates with kind: MutationKind::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 with GapBehavior::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 with GapBehavior::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 Note::DroppedLink.
The link pushes phase to Thursday, and a and b move with it, one day apart as before. With move_projects: false the link lands on each leaf. Both a and b start on Thursday with Reason::Asap, and phase shrinks to one day.
Only open moves, to 2026-03-06 .. 2026-03-07. done stays on Monday, and the summary's span stretches to cover both.
Note
dhtmlxGantt's JS engine drags a completed child along with its moving summary. Planning Engine keeps it fixed, because "completed work doesn't move" is what use_progress promises. This is one of the three declared date differences from gantt.
link l1 dropped: link between a summary and its own descendant (descendant_links disabled)
Set config.descendant_links: true to keep such links. In block mode the JS engine fails on this setup with a cycle error, while Planning Engine schedules it. That's the third declared difference from gantt.
To reschedule only one summary and the tasks that depend on it, see Reschedule a branch.
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 fails validation.
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 Data Updates.