[Canonical page](<https://docs.dhtmlx.com/planning-engine/js/scheduling/links-constraints-summaries/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# 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](<https://docs.dhtmlx.com/planning-engine/js/scheduling/scheduling-a-project.md>) 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 its [`TaskData`](<https://docs.dhtmlx.com/planning-engine/js/api/types/task-data.md>). 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/link-data.md>) object 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 `gap_behavior: "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_progress` is on and `progress` is `1`

Nothing in this guide throws at scheduling time. A constraint the links can't satisfy becomes a `constraint-violation` conflict, a link that can't apply becomes a `dropped-link` note, and a dependency cycle becomes an entry in `cycles`. All three are data in the [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md>). Only invalid input throws an [`InputError`](<https://docs.dhtmlx.com/planning-engine/js/api/types/errors.md#inputerror>), for example a dated constraint without a date or a non-finite lag. See [Reading results](<https://docs.dhtmlx.com/planning-engine/js/scheduling/reading-results.md>) for the full result shape.

To see which rule placed a task, pass `reasons: true` in the solve options. Each update then carries a [`reason`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md#reason>) object 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"`.

## 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 numeric codes `0`-`3` (or `"0"`-`"3"`) are accepted too, in the same order.

`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](<https://docs.dhtmlx.com/planning-engine/js/scheduling/working-time-calendars.md>) for how calendars define working time.

### Chaining tasks with lag

```js
import { schedule } from "plen-js";

const result = schedule({
  tasks: [
    { id: 1, start_date: "2026-03-02", duration: 3 },
    { id: 2, start_date: "2026-03-02", duration: 2 },
    { id: 3, start_date: "2026-03-02", duration: 2 },
  ],
  links: [
    // Task 2 starts 2 working days after task 1 finishes.
    { id: 1, source: 1, target: 2, type: "fs", lag: 2 },
    // Task 3 starts 1 working day after task 1 starts.
    { id: 2, source: 1, target: 3, type: "ss", lag: 1 },
  ],
});

for (const update of result.updates) {
  console.log(`task ${update.id}: ${update.start_date} .. ${update.end_date}`);
}
```

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 `lag_unit` on that link:

```js
import { schedule } from "plen-js";

const result = schedule({
  tasks: [
    { id: "pour", start_date: "2026-03-02", duration: 1 },
    { id: "strip", start_date: "2026-03-02", duration: 1 },
  ],
  links: [{ id: "cure", source: "pour", target: "strip", lag: 4, lag_unit: "hour" }],
});

for (const update of result.updates) {
  console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`);
}
```

`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.

### Links that get dropped

A link that can't apply is skipped and reported as a `dropped-link` note with a `reason`. The rest of the plan still runs. A link is dropped 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_links` is `false` (the default)
- an endpoint is a summary with `auto_scheduling: false` and summaries expand to leaves (see [Summary tasks](<https://docs.dhtmlx.com/planning-engine/js/scheduling/links-constraints-summaries.md#summary-tasks>))

A `lag` that isn't a finite number is different. `NaN` or `Infinity` throws an `InputError` with the message `"Numbers must be finite"` before anything is scheduled.

## Constraints

A constraint is set per task with `constraint_type` ([`ConstraintType`](<https://docs.dhtmlx.com/planning-engine/js/api/types/common.md#constrainttype>)) and, for the six dated types, `constraint_date`:

| `constraint_type` | 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 `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 still invalid input in strict mode, whatever `apply_constraints` says: it throws an `InputError` at `tasks[i].constraint_date`.

### Holding a task back with a dated constraint

```js
import { schedule } from "plen-js";

const result = schedule(
  {
    config: { apply_constraints: true },
    tasks: [
      {
        id: "install",
        start_date: "2026-03-02",
        duration: 2,
        constraint_type: "snet",
        constraint_date: "2026-03-05",
      },
    ],
  },
  { reasons: true },
);

for (const update of result.updates) {
  console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`, update.reason);
}
```

Output:

```
install: 2026-03-05 00:00 .. 2026-03-07 00:00 { kind: 'constraint' }
```

`install` moves from Monday to Thursday, and its reason is `"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 in [`conflicts`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md#conflict>) as a `constraint-violation`:

```js
import { schedule } from "plen-js";

const result = schedule({
  config: { apply_constraints: true },
  tasks: [
    { id: "build", start_date: "2026-03-02", duration: 3 },
    {
      id: "demo",
      start_date: "2026-03-03",
      duration: 1,
      constraint_type: "mso",
      constraint_date: "2026-03-03",
    },
  ],
  links: [{ id: 1, source: "build", target: "demo" }],
});

for (const conflict of result.conflicts) {
  if (conflict.kind === "constraint-violation") {
    const { task, constraint, violation, required, actual } = conflict;
    console.log(`${task}: ${constraint} ${violation}, required ${required}, actual ${actual}`);
  }
}
```

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. `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:

```js
import { schedule } from "plen-js";

const result = schedule({
  config: { apply_constraints: true },
  tasks: [
    { id: "order-food", start_date: "2026-03-02", duration: 1, constraint_type: "alap" },
    {
      id: "party",
      start_date: "2026-03-06",
      duration: 1,
      constraint_type: "mso",
      constraint_date: "2026-03-06",
    },
  ],
  links: [{ id: 1, source: "order-food", target: "party" }],
});

for (const update of result.updates) {
  console.log(`${update.id}: ${update.start_date} .. ${update.end_date}`);
}
```

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 `config.schedule_from_end` and `config.project_end` instead. See [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/js/api/types/scheduling-config.md>).

### Inheriting a summary's constraint

With `config.project_constraint` on, a task without its own constraint inherits the nearest ancestor's:

```js
import { schedule } from "plen-js";

const result = schedule({
  config: { apply_constraints: true, project_constraint: true },
  tasks: [
    { id: "phase", type: "summary", constraint_type: "snet", constraint_date: "2026-03-04" },
    { id: "step", parent: "phase", start_date: "2026-03-02", duration: 1 },
  ],
});

for (const update of result.updates) {
  console.log(`${update.id} (${update.kind}): ${update.start_date} .. ${update.end_date}`);
}
```

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 an update with `kind: "summary"`. With `project_constraint: false`, nothing in this example moves. `config.group_mode` turns inheritance off again, and 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:

```js
import { schedule } from "plen-js";

function plan(gapBehavior) {
  const result = schedule(
    {
      config: { gap_behavior: gapBehavior },
      tasks: [
        { id: "design", start_date: "2026-03-02", duration: 1 },
        { id: "review", start_date: "2026-03-05", duration: 1 },
      ],
      links: [{ id: 1, source: "design", target: "review" }],
    },
    { includeUnchanged: true },
  );

  for (const update of result.updates) {
    console.log(`${gapBehavior} ${update.id}: starts ${update.start_date}`);
  }
}

plan("preserve");
plan("compress");
```

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 (`type: "summary"`, or its input alias `"project"`) groups child tasks through their `parent` field. Its span is always rolled up from its children. 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 with `gap_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 with `gap_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.

### Moving a summary as a block

```js
import { schedule } from "plen-js";

function plan(moveProjects) {
  const result = schedule(
    {
      config: { move_projects: moveProjects },
      tasks: [
        { id: "prep", start_date: "2026-03-02", duration: 3 },
        { id: "phase", type: "summary" },
        { id: "a", parent: "phase", start_date: "2026-03-02", duration: 1 },
        { id: "b", parent: "phase", start_date: "2026-03-03", duration: 1 },
      ],
      links: [{ id: 1, source: "prep", target: "phase" }],
    },
    { reasons: true },
  );

  for (const update of result.updates) {
    console.log(
      `${update.id} ${update.kind}: ${update.start_date} .. ${update.end_date}`,
      JSON.stringify(update.reason),
    );
  }
}

plan(true);
plan(false);
```

The `phase` summary has no dates of its own, so its span comes from `a` and `b`. With `move_projects: true` the output is:

```
a task: 2026-03-05 00:00 .. 2026-03-06 00:00 {"kind":"summary-translation","summaryId":"phase"}
b task: 2026-03-06 00:00 .. 2026-03-07 00:00 {"kind":"summary-translation","summaryId":"phase"}
phase summary: 2026-03-05 00:00 .. 2026-03-07 00:00 {"kind":"asap","linkId":1,"sourceId":"prep"}
```

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 an `"asap"` reason, 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:

```js
import { schedule } from "plen-js";

const result = schedule({
  config: { use_progress: true },
  tasks: [
    { id: "prep", start_date: "2026-03-02", duration: 3 },
    { id: "phase", type: "summary" },
    { id: "done", parent: "phase", start_date: "2026-03-02", duration: 1, progress: 1 },
    { id: "open", parent: "phase", start_date: "2026-03-03", duration: 1 },
  ],
  links: [{ id: 1, source: "prep", target: "phase" }],
});

for (const update of result.updates) {
  console.log(update.id, update.start_date ?? "-", update.end_date ?? "-");
}
```

Output:

```
open 2026-03-06 00:00 2026-03-07 00:00
phase - 2026-03-07 00:00
```

Only `open` moves. `done` stays on Monday, and the summary's span stretches to cover both. The `phase` update carries only `end_date`, because its start didn't change.

:::note
A completed child stays fixed while its summary moves, because "completed work doesn't move" is what `use_progress` promises.
:::

### Linking a summary to its own children

By default, a link between a summary and one of its descendants is dropped:

```js
import { schedule } from "plen-js";

const result = schedule({
  tasks: [
    { id: "phase", type: "summary" },
    { id: "kickoff", parent: "phase", start_date: "2026-03-02", duration: 1 },
  ],
  links: [{ id: "l1", source: "phase", target: "kickoff", type: "ss" }],
});

for (const note of result.notes) {
  if (note.kind === "dropped-link") {
    console.log(`link ${note.link} dropped: ${note.reason}`);
  }
}
```

Output:

```
link l1 dropped: link between a summary and its own descendant (descendant_links disabled)
```

Set `config.descendant_links: true` to keep such links. Planning Engine then schedules them, in block mode too.

To reschedule only one summary and the tasks that depend on it, see [Reschedule a branch](<https://docs.dhtmlx.com/planning-engine/js/how-to/reschedule-a-branch.md>).

## 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`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md#cycle>), with its `tasks` and `links`
- returns `analytics.status: "invalid"` if you asked for analytics
- still installs a baseline on a [`Project`](<https://docs.dhtmlx.com/planning-engine/js/api/project.md>), 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 throws an `InputError` at `tasks[i].parent`.

### Reporting cycles

```js
import { Project } from "plen-js";

const project = new Project();
const result = project.solve(
  {
    tasks: [
      { id: "a", start_date: "2026-03-02", duration: 1 },
      { id: "b", start_date: "2026-03-02", duration: 1 },
      { id: "c", start_date: "2026-03-02", duration: 1 },
    ],
    links: [
      { id: "ab", source: "a", target: "b" },
      { id: "ba", source: "b", target: "a" },
      { id: "bc", source: "b", target: "c" },
    ],
  },
  { analytics: { criticalPath: true } },
);

console.log("updates:", result.updates.length);
for (const cycle of result.cycles) {
  console.log("cycle: tasks", cycle.tasks, "links", cycle.links);
}
console.log("analytics:", result.analytics?.status);
```

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`](<https://docs.dhtmlx.com/planning-engine/js/api/project-apply.md>), then solve the current state again with `solve(null)`:

```js
import { Project } from "plen-js";

const project = new Project();
const result = project.solve({
  tasks: [
    { id: "a", start_date: "2026-03-02", duration: 1 },
    { id: "b", start_date: "2026-03-02", duration: 1 },
  ],
  links: [
    { id: "ab", source: "a", target: "b" },
    { id: "ba", source: "b", target: "a" },
  ],
});

const cycle = result.cycles[0];
if (cycle) {
  const culprit = cycle.links[cycle.links.length - 1];
  project.apply({ links: { remove: [culprit] } });

  const fixed = project.solve(null);
  console.log("cycles:", fixed.cycles.length);
  for (const update of fixed.updates) {
    console.log(`${update.id} starts ${update.start_date}`);
  }
}
```

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](<https://docs.dhtmlx.com/planning-engine/js/live-projects/editing-a-project.md>).

## See also

- [`LinkData`](<https://docs.dhtmlx.com/planning-engine/js/api/types/link-data.md>) and [`TaskData`](<https://docs.dhtmlx.com/planning-engine/js/api/types/task-data.md>)
- [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/js/api/types/schedule-result.md>) for `conflicts`, `notes`, `cycles` and `reason`
- [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/js/api/types/scheduling-config.md>) for `apply_constraints`, `gap_behavior`, `move_projects`, `project_constraint`, `descendant_links` and `use_progress`
- [Critical path and slack](<https://docs.dhtmlx.com/planning-engine/js/scheduling/critical-path-and-slack.md>)
