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 set on 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 connects a Source to a Target with 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 when Config.ApplyConstraints 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 always comes 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.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 Config.UseProgress is on and Progress is 1

Nothing in this guide throws at scheduling time. A constraint the links can't satisfy becomes a Conflict.ConstraintViolation, 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. Only invalid input throws an InputException, 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, set SolveOptions.Reasons to 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.

A link's LinkType 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 Config.DurationUnit unless the link sets its own LagUnit. See Working time and calendars for how calendars define working time.

Chaining tasks with lag#

LinkData needs Id, Source and Target. LinkType defaults to Fs and Lag to 0:

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;

var data = new ProjectData
{
    Tasks =
    [
        new TaskData(1) { StartDate = monday, Duration = 3 },
        new TaskData(2) { StartDate = monday, Duration = 2 },
        new TaskData(3) { StartDate = monday, Duration = 2 },
    ],
    Links =
    [
        // Task 2 starts 2 working days after task 1 finishes.
        new LinkData { Id = 1, Source = 1, Target = 2, LinkType = LinkType.Fs, Lag = 2 },
        // Task 3 starts 1 working day after task 1 starts.
        new LinkData { Id = 2, Source = 1, Target = 3, LinkType = LinkType.Ss, Lag = 1 },
    ],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
{
    Console.WriteLine($"task {update.Id}: {update.StartDate} .. {update.EndDate}");
}

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:

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;

var data = new ProjectData
{
    Tasks =
    [
        new TaskData("pour") { StartDate = monday, Duration = 1 },
        new TaskData("strip") { StartDate = monday, Duration = 1 },
    ],
    Links =
    [
        new LinkData { Id = "cure", Source = "pour", Target = "strip", Lag = 4, LagUnit = TimeUnit.Hour },
    ],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
{
    Console.WriteLine($"{update.Id}: {update.StartDate} .. {update.EndDate}");
}

Output:

strip: 2026-03-03 12:00 .. 2026-03-04 12:00

pour ends on Monday evening. Four working hours on the default 08:00-12:00, 13:00-17:00 calendar put the start of strip at Tuesday 12:00.

Note

Without a per-link LagUnit, lag is measured in Config.DurationUnit.

Waiting for several predecessors#

A task with several incoming links must satisfy all of them. In a forward project, the latest date any link requires wins:

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;

var data = new ProjectData
{
    Tasks =
    [
        new TaskData("backend") { StartDate = monday, Duration = 2 },
        new TaskData("frontend") { StartDate = monday, Duration = 4 },
        new TaskData("release") { StartDate = monday, Duration = 1 },
    ],
    Links =
    [
        new LinkData { Id = 1, Source = "backend", Target = "release" },
        new LinkData { Id = 2, Source = "frontend", Target = "release" },
    ],
};

var result = Engine.Schedule(data, new SolveOptions { Reasons = true });
foreach (var update in result.Updates)
{
    Console.WriteLine($"{update.Id}: {update.StartDate} .. {update.EndDate} {update.Reason}");
}

Output:

release: 2026-03-06 00:00 .. 2026-03-07 00:00 Asap { LinkId = 2, SourceId = frontend }

frontend finishes last, so its link drives release to Friday. The Reason.Asap names that driving link and predecessor.

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 Config.DescendantLinks is false (the default)
  • an endpoint is a summary with AutoScheduling = false and summaries expand to leaves (see Summary tasks)
using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;

var data = new ProjectData
{
    Tasks = [new TaskData("a") { StartDate = monday, Duration = 1 }],
    Links = [new LinkData { Id = "l1", Source = "a", Target = "ghost" }],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var note in result.Notes)
{
    if (note is Note.DroppedLink dropped)
    {
        Console.WriteLine($"link {dropped.Link} dropped: {dropped.Reason}");
    }
}

Output:

link l1 dropped: unknown target task "ghost"

A Lag that isn't a finite number is different: strict validation rejects the input with an InputException at 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 Config.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#

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;
var thursday = Moment.FromYmdHm(2026, 3, 5, 0, 0)!.Value;

var data = new ProjectData
{
    Config = new SchedulingConfig { ApplyConstraints = true },
    Tasks =
    [
        new TaskData("install")
        {
            StartDate = monday,
            Duration = 2,
            ConstraintType = ConstraintType.Snet,
            ConstraintDate = thursday,
        },
    ],
};

var result = Engine.Schedule(data, new SolveOptions { Reasons = true });
foreach (var update in result.Updates)
{
    Console.WriteLine($"{update.Id}: {update.StartDate} .. {update.EndDate} ({update.Reason})");
}

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.

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:

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;
var tuesday = Moment.FromYmdHm(2026, 3, 3, 0, 0)!.Value;

var data = new ProjectData
{
    Config = new SchedulingConfig { ApplyConstraints = true },
    Tasks =
    [
        new TaskData("build") { StartDate = monday, Duration = 3 },
        new TaskData("demo")
        {
            StartDate = tuesday,
            Duration = 1,
            ConstraintType = ConstraintType.Mso,
            ConstraintDate = tuesday,
        },
    ],
    Links = [new LinkData { Id = 1, Source = "build", Target = "demo" }],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var conflict in result.Conflicts)
{
    if (conflict is Conflict.ConstraintViolation v)
    {
        Console.WriteLine($"{v.Task}: {v.Constraint} {v.Violation}, required {v.Required}, actual {v.Actual}");
    }
}
Console.WriteLine($"updates: {result.Updates.Count}");

Output:

demo: Mso LatestStart, required 2026-03-03 00:00, actual 2026-03-05 00:00
updates: 0

build finishes on Wednesday, so the link wants demo to start on Thursday. The Mso pin keeps it on Tuesday and nothing moves. 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:

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;
var friday = Moment.FromYmdHm(2026, 3, 6, 0, 0)!.Value;

var data = new ProjectData
{
    Config = new SchedulingConfig { ApplyConstraints = true },
    Tasks =
    [
        new TaskData("order-food")
        {
            StartDate = monday,
            Duration = 1,
            ConstraintType = ConstraintType.Alap,
        },
        new TaskData("party")
        {
            StartDate = friday,
            Duration = 1,
            ConstraintType = ConstraintType.Mso,
            ConstraintDate = friday,
        },
    ],
    Links = [new LinkData { Id = 1, Source = "order-food", Target = "party" }],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
{
    Console.WriteLine($"{update.Id}: {update.StartDate} .. {update.EndDate}");
}

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.ScheduleFromEnd and Config.ProjectEnd instead. See SchedulingConfig.

Inheriting a summary's constraint#

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

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;
var wednesday = Moment.FromYmdHm(2026, 3, 4, 0, 0)!.Value;

var data = new ProjectData
{
    Config = new SchedulingConfig { ApplyConstraints = true, ProjectConstraint = true },
    Tasks =
    [
        new TaskData("phase")
        {
            TaskType = TaskType.Summary,
            ConstraintType = ConstraintType.Snet,
            ConstraintDate = wednesday,
        },
        new TaskData("step") { Parent = "phase", StartDate = monday, Duration = 1 },
    ],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
{
    Console.WriteLine($"{update.Id} ({update.Kind}): {update.StartDate} .. {update.EndDate}");
}

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 = false, nothing in this example moves. Config.GroupMode turns inheritance off again. That's the only thing GroupMode does.

Gap behaviour#

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

Keeping or closing a gap#

The same data, scheduled with both settings:

using Plen;

foreach (var gap in new[] { GapBehavior.Preserve, GapBehavior.Compress })
{
    Plan(gap);
}

static void Plan(GapBehavior gapBehavior)
{
    var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;
    var thursday = Moment.FromYmdHm(2026, 3, 5, 0, 0)!.Value;

    var data = new ProjectData
    {
        Config = new SchedulingConfig { GapBehavior = gapBehavior },
        Tasks =
        [
            new TaskData("design") { StartDate = monday, Duration = 1 },
            new TaskData("review") { StartDate = thursday, Duration = 1 },
        ],
        Links = [new LinkData { Id = 1, Source = "design", Target = "review" }],
    };

    var result = Engine.Schedule(data, new SolveOptions { IncludeUnchanged = true });
    foreach (var update in result.Updates)
    {
        Console.WriteLine($"{gapBehavior} {update.Id}: starts {update.StartDate}");
    }
}

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 property. 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.MoveProjects picks one of two modes for links that touch a summary:

  • Block mode (MoveProjects = 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 (MoveProjects = 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 AutoScheduling = false, and, with Config.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.

With Config.StrictCalendar on, a block that lands on non-working time is reported as a Conflict.CalendarNonWorking with the proposed and snapped dates.

Moving a summary as a block#

using Plen;

Plan(moveProjects: true);
Plan(moveProjects: false);

static void Plan(bool moveProjects)
{
    var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;
    var tuesday = Moment.FromYmdHm(2026, 3, 3, 0, 0)!.Value;

    var data = new ProjectData
    {
        Config = new SchedulingConfig { MoveProjects = moveProjects },
        Tasks =
        [
            new TaskData("prep") { StartDate = monday, Duration = 3 },
            new TaskData("phase") { TaskType = TaskType.Summary },
            new TaskData("a") { Parent = "phase", StartDate = monday, Duration = 1 },
            new TaskData("b") { Parent = "phase", StartDate = tuesday, Duration = 1 },
        ],
        Links = [new LinkData { Id = 1, Source = "prep", Target = "phase" }],
    };

    var result = Engine.Schedule(data, new SolveOptions { Reasons = true });
    Console.WriteLine($"MoveProjects = {moveProjects}");
    foreach (var update in result.Updates)
    {
        Console.WriteLine($"  {update.Id} {update.Kind}: {update.StartDate} .. {update.EndDate} {update.Reason}");
    }
}

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 the link lands on each leaf. Both start on Thursday with Reason.Asap, and phase shrinks to one day.

Keeping completed work in place#

With Config.UseProgress, a child with Progress = 1 is completed and doesn't move with its block:

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;
var tuesday = Moment.FromYmdHm(2026, 3, 3, 0, 0)!.Value;

var data = new ProjectData
{
    Config = new SchedulingConfig { UseProgress = true },
    Tasks =
    [
        new TaskData("prep") { StartDate = monday, Duration = 3 },
        new TaskData("phase") { TaskType = TaskType.Summary },
        new TaskData("done") { Parent = "phase", StartDate = monday, Duration = 1, Progress = 1 },
        new TaskData("open") { Parent = "phase", StartDate = tuesday, Duration = 1 },
    ],
    Links = [new LinkData { Id = 1, Source = "prep", Target = "phase" }],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var update in result.Updates)
{
    Console.WriteLine($"{update.Id}: {update.StartDate} .. {update.EndDate}");
}

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 stretches to cover both. Its start didn't change, so the phase update carries only the new EndDate.

A completed child stays fixed, because "completed work doesn't move" is what UseProgress promises.

Linking a summary to its own children#

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

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;

var data = new ProjectData
{
    Tasks =
    [
        new TaskData("phase") { TaskType = TaskType.Summary },
        new TaskData("kickoff") { Parent = "phase", StartDate = monday, Duration = 1 },
    ],
    Links = [new LinkData { Id = "l1", Source = "phase", Target = "kickoff", LinkType = LinkType.Ss }],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var note in result.Notes)
{
    if (note is Note.DroppedLink dropped)
    {
        Console.WriteLine($"link {dropped.Link} dropped: {dropped.Reason}");
    }
}

Output:

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

Set Config.DescendantLinks = 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:

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.

Reporting cycles#

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;

var data = new ProjectData
{
    Tasks =
    [
        new TaskData("a") { StartDate = monday, Duration = 1 },
        new TaskData("b") { StartDate = monday, Duration = 1 },
        new TaskData("c") { StartDate = monday, Duration = 1 },
    ],
    Links =
    [
        new LinkData { Id = "ab", Source = "a", Target = "b" },
        new LinkData { Id = "ba", Source = "b", Target = "a" },
        new LinkData { Id = "bc", Source = "b", Target = "c" },
    ],
};

var project = new Project();
var options = new SolveOptions { Analytics = new AnalyticsOptions { CriticalPath = true } };
var result = project.Solve(data, options);

Console.WriteLine($"updates: {result.Updates.Count}");
foreach (var cycle in result.Cycles)
{
    Console.WriteLine($"cycle: tasks [{string.Join(", ", cycle.Tasks)}], links [{string.Join(", ", cycle.Links)}]");
}
Console.WriteLine($"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.

A link whose Source and Target are the same task shows up as a cycle with one task:

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;

var data = new ProjectData
{
    Tasks = [new TaskData("a") { StartDate = monday, Duration = 1 }],
    Links = [new LinkData { Id = "self", Source = "a", Target = "a" }],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var cycle in result.Cycles)
{
    Console.WriteLine($"cycle: tasks [{string.Join(", ", cycle.Tasks)}], links [{string.Join(", ", cycle.Links)}]");
}

Output:

cycle: tasks [a], links [self]

Breaking a cycle#

Remove one of the cycle's links with Project.Apply, then solve the current state again:

using Plen;

var monday = Moment.FromYmdHm(2026, 3, 2, 0, 0)!.Value;

var data = new ProjectData
{
    Tasks =
    [
        new TaskData("a") { StartDate = monday, Duration = 1 },
        new TaskData("b") { StartDate = monday, Duration = 1 },
    ],
    Links =
    [
        new LinkData { Id = "ab", Source = "a", Target = "b" },
        new LinkData { Id = "ba", Source = "b", Target = "a" },
    ],
};

var project = new Project();
var result = project.Solve(data, new SolveOptions());

if (result.Cycles.Count > 0)
{
    var culprit = result.Cycles[0].Links[^1];
    var changes = new SchedulingChanges();
    changes.Links.Remove.Add(culprit);
    project.Apply(changes);

    result = project.Solve(null, new SolveOptions());
    Console.WriteLine($"cycles: {result.Cycles.Count}");
    foreach (var update in result.Updates)
    {
        Console.WriteLine($"{update.Id} starts {update.StartDate}");
    }
}

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#