[Canonical page](<https://docs.dhtmlx.com/planning-engine/net/error-handling/>)
[Full documentation index](<https://docs.dhtmlx.com/planning-engine/llms.txt>)


# Error handling

This guide covers how Planning Engine reports failures in .NET: which problems throw, which come back as data inside a normal result, and how to get a live project working again after each kind.

## How failures surface

Planning Engine reports failures with exceptions. Every call is synchronous, so a failure is thrown from the call itself. There's no `Task` to inspect. If you run a long call with `Task.Run`, the same exception comes out of the `await`.

Every engine exception derives from [`PlenException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/plen-exception.md>), and it has two concrete subclasses:

| Exception | Meaning | Project state after the exception |
|---|---|---|
| [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) | The data or options you passed are invalid. | Unchanged. The project keeps the previous accepted state and its cached result. |
| [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) | The input may be fine, but the call doesn't fit the project's current state. | Unchanged. You fix it by calling the right method first. |

[`ProjectException.Code`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) is a [`ProjectError`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md#projecterror>) value:

| Code | `Message` | When it occurs |
|---|---|---|
| `NoBaseline` | `no-baseline` | [`Preview`](<https://docs.dhtmlx.com/planning-engine/net/api/project-preview.md>), [`LoadProfile`](<https://docs.dhtmlx.com/planning-engine/net/api/project-load-profile.md>), [`Evaluate`](<https://docs.dhtmlx.com/planning-engine/net/api/project-evaluate.md>), [`SuggestAssignments`](<https://docs.dhtmlx.com/planning-engine/net/api/project-suggest-assignments.md>) or [`Roster`](<https://docs.dhtmlx.com/planning-engine/net/api/project-roster.md>) on a project that has no solved baseline, including right after `Apply` |
| `StaleBaseline` | `stale-baseline` | A `BaselineId` in the options or in [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/scheduling-changes.md>) doesn't name the current baseline |
| `NeedsResync` | `needs-resync` | [`Solve(null, …)`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>), `Apply` or [`Level`](<https://docs.dhtmlx.com/planning-engine/net/api/project-level.md>) after an `Apply` rejected an operation, until a full `Solve(data, …)` succeeds |

Most of what goes wrong in a plan isn't an exception at all. Dependency cycles, constraint conflicts, dropped links, a planner that didn't converge and rejected [`Apply`](<https://docs.dhtmlx.com/planning-engine/net/api/project-apply.md>) operations come back inside the result. An exception means the call itself couldn't run.

A few framework exceptions (`ArgumentNullException` and two others) cover misuse of the .NET types themselves. They're listed under [Framework exceptions](<https://docs.dhtmlx.com/planning-engine/net/error-handling.md#framework-exceptions>). Valid input throws something else in only [one case](<https://docs.dhtmlx.com/planning-engine/net/error-handling.md#date-overflow-on-summary-link-graphs>).

## Catching errors

`catch (PlenException)` covers every engine error, including those from the [`Plen.Assign`](<https://docs.dhtmlx.com/planning-engine/net/api/assign/overview.md>) skill layer. Catch the subclasses first when you need to tell them apart:

```csharp
using Plen;

var data = new ProjectData
{
    Tasks = [new TaskData(1) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = 2.0 }],
};

try
{
    var result = Engine.Schedule(data, new SolveOptions());
    Console.WriteLine($"{result.Updates.Count} tasks moved");
}
catch (InputException e)
{
    Console.Error.WriteLine($"bad input at {e.Path}: {e.Message}");
}
catch (ProjectException e)
{
    Console.Error.WriteLine($"wrong project state: {e.Code}");
}
```

`Message` holds different things on the two subclasses:

- On an [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) it's only the description, such as `invalid duration`. The field is in [`Path`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md#path>), so read both when you show the error to a user.
- On a [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) it's the kebab-case code (`no-baseline`, `stale-baseline`, `needs-resync`). Branch on [`Code`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md#code>), not on the text.

## Input errors

An [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) carries:

- [`Kind`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md#kind>), an [`InputErrorKind`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md#inputerrorkind>). Calls in this package always produce `Validation`. `Parse` comes only from JSON decoding in the separate `Plen.Wire` package.
- [`Message`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md#message>), a short description such as `invalid duration` or `duplicate task id`.
- [`Path`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md#path>), the offending field in its JSON (wire) spelling: `tasks[0].duration`, `assignments[1].id`, `calendars[0].worktime.days`, `scope.resources`. It's `null` for parse errors and for refusals that concern the whole state, such as [`SuggestAssignments`](<https://docs.dhtmlx.com/planning-engine/net/api/project-suggest-assignments.md>) on a cyclic project.

Paths use the snake_case wire names, not the C# property names. [`TaskData.ConstraintDate`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md>) on the first task is reported as `tasks[0].constraint_date`.

With the default [`Validation.Strict`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/validation.md>), [`Solve(data, …)`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) and [`Engine.Schedule`](<https://docs.dhtmlx.com/planning-engine/net/api/engine-schedule.md>) reject the input on the first problem they find. Strict validation rejects, among other things:

- a leaf task with fewer than two of `StartDate`, `EndDate` and `Duration`, or with all three when they don't agree
- a dated constraint (`Snet`, `Fnlt`, ...) without a `ConstraintDate`
- duplicate task, link or calendar ids (`1` and `"1"` count as the same id)
- an unknown `Parent`, or a cycle in the `Parent` hierarchy
- a summary with `StartDate` + `Duration` (a summary takes both dates or neither)
- a negative `Duration`, a `Priority` above 1000, a negative `LevelingDelay`
- an assignment to an unknown task or resource, or with `Value = 0`
- a [`Worktime.Days`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/worktime.md#days>) array that doesn't have exactly 7 entries, and calendars with no working time

The full list per field is on the reference pages for [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/project-data.md>), [`TaskData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/task-data.md>) and [`AssignmentData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/assignment-data.md>).

### Reporting a validation error with its path

```csharp
using Plen;

var data = new ProjectData
{
    Tasks = [new TaskData(1) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = -1.0 }],
};

try
{
    var result = Engine.Schedule(data, new SolveOptions());
    Console.WriteLine($"{result.Updates.Count} tasks moved");
}
catch (InputException e)
{
    // Prints "tasks[0].duration: invalid duration"
    Console.Error.WriteLine($"{e.Path ?? "<input>"}: {e.Message}");
}
```

### A rejected replacement keeps the old state

On a live [`Project`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/project.md>), a [`Solve(data, …)`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) that fails validation installs nothing. The project keeps its accepted baseline, and `Solve(null, …)` still returns the result you had before:

```csharp
using Plen;

var options = new SolveOptions();
var project = new Project();
var first = project.Solve(new ProjectData { Tasks = [Leaf(1, 2.0)] }, options);

try
{
    project.Solve(new ProjectData { Tasks = [Leaf(2, -1.0)] }, options);
}
catch (InputException e)
{
    Console.Error.WriteLine($"replacement rejected at {e.Path}: {e.Message}");
}

// Still the first data set.
var again = project.Solve(null, options);
Console.WriteLine(again.Equals(first)); // True

static TaskData Leaf(Id id, double duration) =>
    new(id) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = duration };
```

## Project errors

A [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) tells you the call came at the wrong moment. Each code has one recovery:

| Code | Recovery |
|---|---|
| `NoBaseline` | Call `Solve` first. After [`Apply`](<https://docs.dhtmlx.com/planning-engine/net/api/project-apply.md>), call `Solve(null, …)` before [`Preview`](<https://docs.dhtmlx.com/planning-engine/net/api/project-preview.md>), [`LoadProfile`](<https://docs.dhtmlx.com/planning-engine/net/api/project-load-profile.md>), [`Evaluate`](<https://docs.dhtmlx.com/planning-engine/net/api/project-evaluate.md>) or [`SuggestAssignments`](<https://docs.dhtmlx.com/planning-engine/net/api/project-suggest-assignments.md>). |
| `StaleBaseline` | The project was solved, edited or leveled since you read the token. Take [`BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#baselineid>) from the latest result and retry. |
| `NeedsResync` | Send the full data again with [`Solve(data, …)`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>). Nothing else is accepted until that call succeeds. |

### Telling the codes apart

Exception filters let each code get its own `catch` block:

```csharp
using Plen;

// An unsolved project: prints "solve the project first".
Console.WriteLine(PreviewOrExplain(new Project(), new SchedulingChanges(), ""));

static string PreviewOrExplain(Project project, SchedulingChanges changes, string baselineId)
{
    try
    {
        var result = project.Preview(changes, new PreviewOptions { BaselineId = baselineId });
        return $"{result.Updates.Count} tasks would move";
    }
    catch (ProjectException e) when (e.Code == ProjectError.NoBaseline)
    {
        return "solve the project first";
    }
    catch (ProjectException e) when (e.Code == ProjectError.StaleBaseline)
    {
        return "the plan changed since you loaded it; refresh and try again";
    }
    catch (ProjectException e) when (e.Code == ProjectError.NeedsResync)
    {
        return "reload the full project";
    }
    catch (InputException e)
    {
        return $"invalid change at {e.Path}: {e.Message}";
    }
}
```

[`Preview`](<https://docs.dhtmlx.com/planning-engine/net/api/project-preview.md>) checks both [`options.BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/preview-options.md#baselineid>) and [`changes.BaselineId`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/scheduling-changes.md#baselineid>). Baseline tokens are opaque and unique to the process, so never store them across processes or compare them in tests.

### Recovering from `NeedsResync`

A rejected operation in [`Apply`](<https://docs.dhtmlx.com/planning-engine/net/api/project-apply.md>) doesn't throw. It's listed in [`ApplyReport.Rejected`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/apply-report.md>) and sets [`NeedsResync`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/apply-report.md#needsresync>). From then on, `Solve(null, …)`, `Apply` and [`Level`](<https://docs.dhtmlx.com/planning-engine/net/api/project-level.md>) throw a [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) with `Code == ProjectError.NeedsResync`, because your model and the project's model no longer agree. Resync with the full data you hold:

```csharp
using Plen;

var data = new ProjectData
{
    Tasks = [new TaskData(1) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = 2.0 }],
};
var project = new Project();
project.Solve(data, new SolveOptions());

// Task 999 doesn't exist: the operation is rejected.
var changes = new SchedulingChanges();
changes.Tasks.Update.Add(new TaskUpdate(999) { Duration = 3.0 });

var result = Commit(project, changes, data);
Console.WriteLine($"resynced, baseline {result.BaselineId}");

static ScheduleResult Commit(Project project, SchedulingChanges changes, ProjectData sourceOfTruth)
{
    var options = new SolveOptions();
    var report = project.Apply(changes);
    if (report.NeedsResync)
    {
        foreach (var rejected in report.Rejected)
            Console.Error.WriteLine($"rejected {rejected.Path}: {rejected.Message}");
        return project.Solve(sourceOfTruth, options);
    }
    return project.Solve(null, options);
}
```

`Solve` deep-copies the data it receives, so you can pass the same [`ProjectData`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/project-data.md>) object again. If the resync data itself fails validation, the [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) leaves the project in the `NeedsResync` state. Fix the data and call [`Solve(data, …)`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) again. The [recovery how-to](<https://docs.dhtmlx.com/planning-engine/net/how-to/recover-from-rejected-changes.md>) covers this flow in an editor.

## Results that are not errors

These all come back in a normal return value. Check them on every result you show to a user:

| Where | What it means |
|---|---|
| [`ScheduleResult.Cycles`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md>) | Dependency cycles. A cyclic solve returns unchanged dates and empty `Updates`, and still installs a baseline. |
| [`ScheduleResult.Conflicts`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md>) | [`Conflict.ConstraintViolation`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md#constraintviolation>) (a link and a hard constraint disagree, the constraint wins) and [`Conflict.CalendarNonWorking`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md#calendarnonworking>) |
| [`ScheduleResult.Notes`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md>) | Input the engine dropped ([`Note.DroppedLink`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedlink>), [`Note.DroppedTask`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedtask>), [`Note.DroppedAssignment`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#droppedassignment>)) or fixed ([`Note.Repaired`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#repaired>)) |
| [`ScheduleResult.Converged`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/schedule-result.md#converged>) | `false` when the planner didn't reach a fixed point within [`Config.MaxIterations`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/scheduling-config.md#maxiterations>). The dates are still valid output. |
| [`ApplyReport.Rejected`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/rejected-operation.md>) | Operations that [`Apply`](<https://docs.dhtmlx.com/planning-engine/net/api/project-apply.md>) refused. The other operations in the batch were still applied. |
| [`CandidateResult.Error`](<https://docs.dhtmlx.com/planning-engine/net/api/core/evaluate/candidate-result.md>) | An [`Evaluate`](<https://docs.dhtmlx.com/planning-engine/net/api/project-evaluate.md>) candidate whose change set was rejected. That candidate has no metrics. The other candidates are scored normally. |

### Checking a schedule for problems

[`Conflict`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/conflict.md>) and [`Note`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md>) are closed unions, so read them with pattern matching:

```csharp
using Plen;

var start = Moment.FromYmdHm(2026, 1, 5, 8, 0);
var data = new ProjectData
{
    Tasks =
    [
        new TaskData("a") { StartDate = start, Duration = 1.0 },
        new TaskData("b") { StartDate = start, Duration = 1.0 },
    ],
    // a -> b -> a: a dependency cycle, reported as data.
    Links =
    [
        new LinkData { Id = "l1", Source = "a", Target = "b" },
        new LinkData { Id = "l2", Source = "b", Target = "a" },
    ],
};

var result = Engine.Schedule(data, new SolveOptions());
foreach (var cycle in result.Cycles)
    Console.WriteLine($"cycle through tasks {string.Join(", ", cycle.Tasks)}");
foreach (var conflict in result.Conflicts)
{
    if (conflict is Conflict.ConstraintViolation v)
        Console.WriteLine($"task {v.Task}: required {v.Required}, got {v.Actual}");
}
if (!result.Converged)
    Console.WriteLine("planner hit MaxIterations");
```

Dependency cycles are accepted state. Cycles in the `Parent` hierarchy are different: they're invalid input and throw an [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>).

## Repair instead of reject

[`Validation.Strict`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/validation.md>) suits data your own code produces. For data from a UI or an older store, [`Validation.Repair`](<https://docs.dhtmlx.com/planning-engine/net/api/core/project/validation.md>) coerces it instead of throwing:

- duplicate ids: the last record wins, at the first record's position
- missing dates or durations are derived
- in an inconsistent start/end/duration triple the dates win
- dated constraints without a date are cleared
- invalid `Pauses` lists and assignment slice values are cleared

Each fix shows up as a [`Note.Repaired`](<https://docs.dhtmlx.com/planning-engine/net/api/core/results/note.md#repaired>) with a `Path` and a `Message`, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems still throw an [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>).

```csharp
using Plen;

var first = new TaskData(1) { StartDate = Moment.FromYmdHm(2026, 1, 5, 8, 0), Duration = 1.0 };
var duplicate = first.DeepClone();
duplicate.Duration = 2.0;

var project = new Project(new ProjectOptions { Validation = Validation.Repair });
var result = project.Solve(new ProjectData { Tasks = [first, duplicate] }, new SolveOptions());
foreach (var note in result.Notes)
{
    if (note is Note.Repaired repaired)
        Console.WriteLine($"repaired {repaired.Path}: {repaired.Message}");
}
```

The [messy input how-to](<https://docs.dhtmlx.com/planning-engine/net/how-to/accept-messy-input.md>) shows the repair flow end to end.

## Errors from SkillLayer.Slots

The [`Plen.Assign`](<https://docs.dhtmlx.com/planning-engine/net/api/assign/overview.md>) package throws the same two exceptions as the core. [`SkillLayer.Slots`](<https://docs.dhtmlx.com/planning-engine/net/api/skill-layer-slots.md>) throws:

- an [`InputException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/input-exception.md>) with `Kind == InputErrorKind.Validation` for a bad pool or requirement. `Path` uses the same format as the core: `pool.workers[0].resource`, `requirements[2].count`, `requirements[1].assignments[0]`.
- a [`ProjectException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/project-exception.md>) with `Code == ProjectError.NoBaseline` when the project has no clean solved baseline. Call [`Solve`](<https://docs.dhtmlx.com/planning-engine/net/api/project-solve.md>) before `Slots`, including after [`Apply`](<https://docs.dhtmlx.com/planning-engine/net/api/project-apply.md>).

A method that builds slots and passes them to [`SuggestAssignments`](<https://docs.dhtmlx.com/planning-engine/net/api/project-suggest-assignments.md>) needs only one set of `catch` blocks:

```csharp
using Plen;
using Plen.Assign;

var pool = new Pool
{
    Workers = [new Worker { Resource = "dev1", Skills = [new Skill { Id = "csharp", Value = 1.0 }] }],
};
Requirement[] requirements = [new Requirement("backend", 1, [SkillMatch.AtLeast("csharp", 1.0)])];

try
{
    var result = Staff(new Project(), pool, requirements);
    Console.WriteLine($"{result.Steps.Count} steps");
}
catch (InputException e)
{
    Console.Error.WriteLine($"{e.Path}: {e.Message}");
}
catch (ProjectException e)
{
    // An unsolved project: prints "project state: no-baseline".
    Console.Error.WriteLine($"project state: {e.Message}");
}

static SuggestResult Staff(Project project, Pool pool, IReadOnlyList<Requirement> requirements)
{
    var slots = SkillLayer.Slots(project, pool, requirements);
    return project.SuggestAssignments(slots, new SuggestOptions());
}
```

## Framework exceptions

Outside the engine exceptions, the library throws standard .NET exceptions only for misuse of its types:

| Exception | When |
|---|---|
| `ArgumentNullException` | A public method gets `null` for an argument that isn't declared nullable. [`Id.Str(null)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/id.md#str>) throws it too. |
| `ArgumentOutOfRangeException` | [`Execution.Parallel(threads)`](<https://docs.dhtmlx.com/planning-engine/net/api/core/evaluate/execution.md>) with `threads <= 0`, or [`Moment.ToDateTime()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/moment.md>) on a moment outside years 1-9999 |
| `InvalidOperationException` | [`Patch<T>.Value`](<https://docs.dhtmlx.com/planning-engine/net/api/core/changes/patch.md>) on a patch without a value, or [`Id.Key()`](<https://docs.dhtmlx.com/planning-engine/net/api/core/data/id.md>) of an infinite number |

These point at a bug in the calling code, so you rarely catch them. Avoid them up front instead:

```csharp
using Plen;

var update = new TaskUpdate(1) { Duration = 3.0 };

// TryGetValue never throws; update.StartDate.Value would, because the field is Missing.
if (update.StartDate.TryGetValue(out var startDate))
    Console.WriteLine($"new start {startDate}");
if (update.Duration.TryGetValue(out var duration))
    Console.WriteLine($"new duration {duration}");

// ToDateTime() throws outside years 1-9999; Minutes is always readable.
var moment = new Moment(long.MaxValue / 2);
Console.WriteLine(moment.Minutes);
```

### Date overflow on summary-link graphs

One shape of valid input can throw. In block mode, summary-to-summary links combined with leaf links between the same summaries push each linked group later than the one before. On very large generated graphs the dates leave the supported range, and the solve throws `InvalidOperationException` with the message `minute timestamp in range`. It isn't a [`PlenException`](<https://docs.dhtmlx.com/planning-engine/net/api/core/errors/plen-exception.md>), so `catch (PlenException)` doesn't catch it.

Apart from this case, valid input never throws anything else. The library has no other exception path for problems in a plan.
