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 its 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 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. Only invalid input throws an InputError, for example 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, pass reasons: true in the solve options. Each update then carries a 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".

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 for how calendars define working time.

Chaining tasks with lag#

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:

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.

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)

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) 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#

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.

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 as a constraint-violation:

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:

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.

Inheriting a summary's constraint#

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

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:

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#

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:

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:

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.

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, with its tasks and links
  • returns analytics.status: "invalid" if you asked for analytics
  • 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 throws an InputError at tasks[i].parent.

Reporting cycles#

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, then solve the current state again with solve(null):

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.

See also#