[Canonical page](<https://docs.dhtmlx.com/planning-engine/rust/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 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](<https://docs.dhtmlx.com/planning-engine/rust/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 [`TaskData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/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/rust/api/core/data/link-data.md>) 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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/gap-behavior.md>) (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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/conflict.md>), a link that can't apply becomes a [`Note::DroppedLink`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>), and a dependency cycle becomes a [`Cycle`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/cycle.md>) entry. All three are data in the [`ScheduleResult`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/schedule-result.md>). Errors come only from invalid input, such as a dated constraint without a date or a non-finite lag. See [Reading results](<https://docs.dhtmlx.com/planning-engine/rust/scheduling/reading-results.md>) for the full result shape.

To see which rule placed a task, set `SolveOptions.reasons` to `true`. Each update then carries a [`Reason`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/reason.md>): `Asap` (pushed by a predecessor link), `Alap` (pulled by a successor link), `SummaryTranslation` (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:

| [`LinkType`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/link-type.md>) | 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.duration_unit` unless the link sets its own `lag_unit`. See [Working time and calendars](<https://docs.dhtmlx.com/planning-engine/rust/scheduling/working-time-calendars.md>) for how calendars define working time.

### Chaining tasks with lag

[`LinkData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/link-data.md>) has no constructor, so every field is spelled out:

```rust
use plen_core::{schedule, DateTime, LinkData, LinkType, ProjectData, SolveOptions, TaskData};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(3.0), ..TaskData::new(1) },
            TaskData { start_date: Some(monday), duration: Some(2.0), ..TaskData::new(2) },
            TaskData { start_date: Some(monday), duration: Some(2.0), ..TaskData::new(3) },
        ],
        links: vec![
            // Task 2 starts 2 working days after task 1 finishes.
            LinkData { id: 1.into(), source: 1.into(), target: 2.into(), link_type: LinkType::Fs, lag: 2.0, lag_unit: None },
            // Task 3 starts 1 working day after task 1 starts.
            LinkData { id: 2.into(), source: 1.into(), target: 3.into(), link_type: LinkType::Ss, lag: 1.0, lag_unit: None },
        ],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("task {}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}
```

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:

```rust
use plen_core::{schedule, DateTime, LinkData, LinkType, ProjectData, SolveOptions, TaskData, TimeUnit};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("pour") },
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("strip") },
        ],
        links: vec![LinkData {
            id: "cure".into(),
            source: "pour".into(),
            target: "strip".into(),
            link_type: LinkType::Fs,
            lag: 4.0,
            lag_unit: Some(TimeUnit::Hour),
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}
```

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

### Links that get dropped

A link that can't apply is skipped and reported as a [`Note::DroppedLink`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>) 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.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/rust/scheduling/links-constraints-summaries.md#summary-tasks>))

A `lag` that isn't a finite number is different: strict validation rejects the input with an [`InputError`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/errors/input-error.md>) at `links[i].lag`.

## Constraints

A constraint is set per task with `constraint_type` and, for the six dated types, `constraint_date`:

| [`ConstraintType`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/constraint-type.md>) | 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 invalid input in strict mode, whatever `apply_constraints` says. [`ConstraintType::is_dated`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/constraint-type.md#is_dated>) tells you which types need the date.

### Holding a task back with a dated constraint

```rust
use plen_core::{
    schedule, ConstraintType, DateTime, ProjectData, SchedulingConfig, SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let thursday = DateTime::from_ymd_hm(2026, 3, 5, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { apply_constraints: true, ..SchedulingConfig::default() },
        tasks: vec![TaskData {
            start_date: Some(monday),
            duration: Some(2.0),
            constraint_type: Some(ConstraintType::Snet),
            constraint_date: Some(thursday),
            ..TaskData::new("install")
        }],
        ..ProjectData::default()
    };

    let options = SolveOptions { reasons: true, ..SolveOptions::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end} ({:?})", update.id, update.reason);
        }
    }
    Ok(())
}
```

`install` moves from Monday to Thursday, `2026-03-05 00:00 .. 2026-03-07 00:00`, with [`Reason::Constraint`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/reason.md>). 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 [`Conflict::ConstraintViolation`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/conflict.md#constraintviolation>):

```rust
use plen_core::{
    schedule, Conflict, ConstraintType, DateTime, LinkData, LinkType, ProjectData,
    SchedulingConfig, SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let tuesday = DateTime::from_ymd_hm(2026, 3, 3, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { apply_constraints: true, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(3.0), ..TaskData::new("build") },
            TaskData {
                start_date: Some(tuesday),
                duration: Some(1.0),
                constraint_type: Some(ConstraintType::Mso),
                constraint_date: Some(tuesday),
                ..TaskData::new("demo")
            },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "build".into(),
            target: "demo".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for conflict in &result.conflicts {
        if let Conflict::ConstraintViolation { task, constraint, violation, required, actual } = conflict {
            println!("{task}: {constraint:?} {violation:?}, required {required}, actual {actual}");
        }
    }
    Ok(())
}
```

Output:

```
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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/violation.md>) 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:

```rust
use plen_core::{
    schedule, ConstraintType, DateTime, LinkData, LinkType, ProjectData, SchedulingConfig,
    SolveOptions, TaskData,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let friday = DateTime::from_ymd_hm(2026, 3, 6, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { apply_constraints: true, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData {
                start_date: Some(monday),
                duration: Some(1.0),
                constraint_type: Some(ConstraintType::Alap),
                ..TaskData::new("order-food")
            },
            TaskData {
                start_date: Some(friday),
                duration: Some(1.0),
                constraint_type: Some(ConstraintType::Mso),
                constraint_date: Some(friday),
                ..TaskData::new("party")
            },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "order-food".into(),
            target: "party".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}
```

`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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/scheduling-config.md>).

### Inheriting a summary's constraint

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

```rust
use plen_core::{
    schedule, ConstraintType, DateTime, ProjectData, SchedulingConfig, SolveOptions, TaskData,
    TaskType,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let wednesday = DateTime::from_ymd_hm(2026, 3, 4, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig {
            apply_constraints: true,
            project_constraint: true,
            ..SchedulingConfig::default()
        },
        tasks: vec![
            TaskData {
                task_type: TaskType::Summary,
                constraint_type: Some(ConstraintType::Snet),
                constraint_date: Some(wednesday),
                ..TaskData::new("phase")
            },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(monday),
                duration: Some(1.0),
                ..TaskData::new("step")
            },
        ],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{} ({:?}): {start} .. {end}", update.id, update.kind);
        }
    }
    Ok(())
}
```

`step` moves to Wednesday, and the rolled-up `phase` follows it as a [`MutationKind::Summary`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/mutation-kind.md>) 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.
:::

## Gap behaviour

`config.gap_behavior` decides what happens to the free time between linked tasks:

- [`GapBehavior::Preserve`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/gap-behavior.md>) (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:

```rust
use plen_core::{
    schedule, DateTime, GapBehavior, LinkData, LinkType, ProjectData, SchedulingConfig,
    SolveOptions, TaskData,
};

fn plan(gap_behavior: GapBehavior) -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let thursday = DateTime::from_ymd_hm(2026, 3, 5, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { gap_behavior, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("design") },
            TaskData { start_date: Some(thursday), duration: Some(1.0), ..TaskData::new("review") },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "design".into(),
            target: "review".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let options = SolveOptions { include_unchanged: true, ..SolveOptions::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let Some(start) = update.start_date {
            println!("{gap_behavior:?} {}: starts {start}", update.id);
        }
    }
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    plan(GapBehavior::Preserve)?;
    plan(GapBehavior::Compress)?;
    Ok(())
}
```

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

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

## Summary tasks

A summary ([`TaskType::Summary`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/task-type.md>), 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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/gap-behavior.md>)): 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`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/note.md>).

### Moving a summary as a block

```rust
use plen_core::{
    schedule, DateTime, LinkData, LinkType, ProjectData, SchedulingConfig, SolveOptions, TaskData,
    TaskType,
};

fn plan(move_projects: bool) -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let tuesday = DateTime::from_ymd_hm(2026, 3, 3, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { move_projects, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(3.0), ..TaskData::new("prep") },
            TaskData { task_type: TaskType::Summary, ..TaskData::new("phase") },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(monday),
                duration: Some(1.0),
                ..TaskData::new("a")
            },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(tuesday),
                duration: Some(1.0),
                ..TaskData::new("b")
            },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "prep".into(),
            target: "phase".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let options = SolveOptions { reasons: true, ..SolveOptions::default() };
    let result = schedule(data, &options)?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{} {:?}: {start} .. {end} {:?}", update.id, update.kind, update.reason);
        }
    }
    Ok(())
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    plan(true)?;
    plan(false)?;
    Ok(())
}
```

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 Some(SummaryTranslation { summary_id: Str("phase") })
b Task: 2026-03-06 00:00 .. 2026-03-07 00:00 Some(SummaryTranslation { summary_id: Str("phase") })
phase Summary: 2026-03-05 00:00 .. 2026-03-07 00:00 Some(Asap { link_id: Num(1.0), source_id: Str("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 [`Reason::Asap`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/reason.md>), and `phase` shrinks to one day.

### Keeping completed work in place

With `config.use_progress`, a child with `progress: 1.0` is completed and doesn't move with its block:

```rust
use plen_core::{
    schedule, DateTime, LinkData, LinkType, ProjectData, SchedulingConfig, SolveOptions, TaskData,
    TaskType,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;
    let tuesday = DateTime::from_ymd_hm(2026, 3, 3, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        config: SchedulingConfig { use_progress: true, ..SchedulingConfig::default() },
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(3.0), ..TaskData::new("prep") },
            TaskData { task_type: TaskType::Summary, ..TaskData::new("phase") },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(monday),
                duration: Some(1.0),
                progress: 1.0,
                ..TaskData::new("done")
            },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(tuesday),
                duration: Some(1.0),
                ..TaskData::new("open")
            },
        ],
        links: vec![LinkData {
            id: 1.into(),
            source: "prep".into(),
            target: "phase".into(),
            link_type: LinkType::Fs,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for update in &result.updates {
        if let (Some(start), Some(end)) = (update.start_date, update.end_date) {
            println!("{}: {start} .. {end}", update.id);
        }
    }
    Ok(())
}
```

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

### Linking a summary to its own children

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

```rust
use plen_core::{
    schedule, DateTime, LinkData, LinkType, Note, ProjectData, SolveOptions, TaskData, TaskType,
};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { task_type: TaskType::Summary, ..TaskData::new("phase") },
            TaskData {
                parent: Some("phase".into()),
                start_date: Some(monday),
                duration: Some(1.0),
                ..TaskData::new("kickoff")
            },
        ],
        links: vec![LinkData {
            id: "l1".into(),
            source: "phase".into(),
            target: "kickoff".into(),
            link_type: LinkType::Ss,
            lag: 0.0,
            lag_unit: None,
        }],
        ..ProjectData::default()
    };

    let result = schedule(data, &SolveOptions::default())?;
    for note in &result.notes {
        if let Note::DroppedLink { link, reason } = note {
            println!("link {link} dropped: {reason}");
        }
    }
    Ok(())
}
```

Output:

```
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](<https://docs.dhtmlx.com/planning-engine/rust/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` as a [`Cycle`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/cycle.md>) with its `tasks` and `links`
- returns analytics with [`AnalyticsStatus::Invalid`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/analytics-status.md>) if you asked for any
- still installs a baseline on a [`Project`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/project/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 fails validation.

### Reporting cycles

```rust
use plen_core::{
    AnalyticsOptions, DateTime, LinkData, LinkType, Project, ProjectData, ProjectOptions,
    SolveOptions, TaskData,
};

fn link(id: &str, source: &str, target: &str) -> LinkData {
    LinkData {
        id: id.into(),
        source: source.into(),
        target: target.into(),
        link_type: LinkType::Fs,
        lag: 0.0,
        lag_unit: None,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("a") },
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("b") },
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("c") },
        ],
        links: vec![link("ab", "a", "b"), link("ba", "b", "a"), link("bc", "b", "c")],
        ..ProjectData::default()
    };

    let mut project = Project::new(ProjectOptions::default());
    let options = SolveOptions {
        analytics: AnalyticsOptions { critical_path: true, ..AnalyticsOptions::default() },
        ..SolveOptions::default()
    };
    let result = project.solve(Some(data), &options)?;

    println!("updates: {}", result.updates.len());
    for cycle in &result.cycles {
        let tasks: Vec<String> = cycle.tasks.iter().map(|id| id.to_string()).collect();
        let links: Vec<String> = cycle.links.iter().map(|id| id.to_string()).collect();
        println!("cycle: tasks {tasks:?}, links {links:?}");
    }
    if let Some(analytics) = &result.analytics {
        println!("analytics: {:?}", analytics.status);
    }
    Ok(())
}
```

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/rust/api/core/project/project.md#apply>), then solve the current state again:

```rust
use plen_core::{
    Changes, DateTime, LinkData, LinkType, Project, ProjectData, ProjectOptions,
    SchedulingChanges, SolveOptions, TaskData,
};

fn link(id: &str, source: &str, target: &str) -> LinkData {
    LinkData {
        id: id.into(),
        source: source.into(),
        target: target.into(),
        link_type: LinkType::Fs,
        lag: 0.0,
        lag_unit: None,
    }
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let monday = DateTime::from_ymd_hm(2026, 3, 2, 0, 0).ok_or("invalid date")?;

    let data = ProjectData {
        tasks: vec![
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("a") },
            TaskData { start_date: Some(monday), duration: Some(1.0), ..TaskData::new("b") },
        ],
        links: vec![link("ab", "a", "b"), link("ba", "b", "a")],
        ..ProjectData::default()
    };

    let mut project = Project::new(ProjectOptions::default());
    let result = project.solve(Some(data), &SolveOptions::default())?;

    if let Some(cycle) = result.cycles.first() {
        let culprit = cycle.links.last().cloned().ok_or("cycle without links")?;
        let changes = SchedulingChanges {
            links: Changes { remove: vec![culprit], ..Changes::default() },
            ..SchedulingChanges::default()
        };
        project.apply(&changes)?;

        let result = project.solve(None, &SolveOptions::default())?;
        println!("cycles: {}", result.cycles.len());
        for update in &result.updates {
            if let Some(start) = update.start_date {
                println!("{} starts {start}", update.id);
            }
        }
    }
    Ok(())
}
```

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

## See also

- [`LinkData`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/link-data.md>) and [`LinkType`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/link-type.md>)
- [`ConstraintType`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/constraint-type.md>) and [`Conflict`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/results/conflict.md>)
- [`GapBehavior`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/gap-behavior.md>)
- [`SchedulingConfig`](<https://docs.dhtmlx.com/planning-engine/rust/api/core/data/scheduling-config.md>) for `apply_constraints`, `move_projects`, `project_constraint`, `descendant_links` and `use_progress`
- [Critical path and slack](<https://docs.dhtmlx.com/planning-engine/rust/scheduling/critical-path-and-slack.md>)
