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


# Error handling

This guide covers how the raw Planning Engine exports report failures. It explains which problems come back as an error envelope, which arrive as data inside a successful result, which few cases throw on the host side, and how to get a live project working again after each one.

## How failures surface

Every call that does work ([`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) and the [`JsonProject`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md>) methods except [`clone`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#clone>) and [`free`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#free>)) returns JSON text, and that text is always an [envelope](<https://docs.dhtmlx.com/planning-engine/wasm/wire-format.md#envelope>):

```json
{ "ok": true, "result": { "updates": [], "cycles": [], "conflicts": [], "notes": [], "converged": true } }
```

```json
{ "ok": false, "error": { "kind": "validation", "code": null, "message": "invalid duration", "path": "tasks[0].duration" } }
```

A bad request doesn't make a call throw. It returns `ok: false`, so every call site parses the text and checks `ok` before it reads `result`. Every export is synchronous, so there's no separate async failure path. The browser build's [`init()`](<https://docs.dhtmlx.com/planning-engine/wasm/api/init.md>) is the one async step, and it's covered in [Host-level throws](<https://docs.dhtmlx.com/planning-engine/wasm/error-handling.md#host-level-throws>).

The `error.kind` field tells you whether the input or the call order is at fault:

| `kind` | Meaning | `code` | Project state after the error |
|---|---|---|---|
| `parse` | The JSON text doesn't decode: malformed JSON, a wrong type, a missing required field, or an unknown field where one isn't allowed | `null` | Unchanged |
| `validation` | The JSON decodes, but a value is invalid | `null` | Unchanged. The previous accepted state and its cached result are kept. |
| `project` | The input may be fine, but the call doesn't fit the project's current state | `no-baseline`, `stale-baseline` or `needs-resync` | Unchanged. You fix it by calling the right method first. |

For a `project` error, `message` repeats `code` and `path` is `null`. The full field table is in [Wire format](<https://docs.dhtmlx.com/planning-engine/wasm/wire-format.md#error-table>).

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

### Reading the envelope

A small helper keeps the parsing in one place. It returns `result` and turns an error envelope into an exception with a readable message:

```js
const { schedule } = require("./pkg-node/plen_wasm.js");

function call(text) {
  const envelope = JSON.parse(text);
  if (envelope.ok) return envelope.result;
  const { kind, code, message, path } = envelope.error;
  if (kind === "project") throw new Error(`project state: ${code}`);
  throw new Error(path ? `${path}: ${message}` : `${kind}: ${message}`);
}

const data = {
  tasks: [{ id: 1, start_date: "2026-01-05 08:00", duration: -1 }],
};
try {
  const result = call(schedule(JSON.stringify(data), "{}"));
  console.log(`${result.updates.length} tasks moved`);
} catch (e) {
  console.error(e.message); // "tasks[0].duration: invalid duration"
}
```

The same code works with the browser build (`pkg/`) once [`await init()`](<https://docs.dhtmlx.com/planning-engine/wasm/api/init.md>) has resolved. If your host only [forwards the envelope](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/forward-json-from-a-server.md>), for example a server that returns it as the response body, you don't have to parse it at all.

## Input errors

An input error has `kind` `parse` or `validation`, a short `message` and a `path`:

- `parse` comes from the JSON decoder. Its `path` is always `null`, and the `message` names the problem, for example ``unknown field `resolved`, expected one of `subtree`, `analytics`, `reasons`, `includeUnchanged` `` or `invalid type: string "bad", expected f64`.
- `validation` covers values the decoder or the engine rejects. Engine checks set `path` to the offending field in the form `tasks[0].duration`, `assignments[1].value` or `config.schedule_from_end`. Value checks done by the decoder itself (an invalid date, an unknown enum string, a `priority` out of range, a `week` duration unit) have `path: null`.

With the default [`"validation": "strict"`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#validation-mode>), a [`solve`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) with data or a [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) call rejects the input on the first problem it finds. Strict validation rejects, among other things:

- a leaf task with fewer than two of `start_date`, `end_date` and `duration`, or with all three when they don't agree
- a dated constraint (`snet`, `fnlt`, ...) without a `constraint_date`
- 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 `start_date` + `duration` (a summary takes both dates or neither)
- a negative `duration` or `leveling_delay`, and a `priority` outside 0..1000
- an assignment to an unknown task or resource, or with `value: 0`
- `week`, `month` or `year` duration units, `customWeeks` calendars, and calendars with no working time
- an entirely undated project (no `project_start` and no dated task)

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

:::note
A `priority` outside 0..1000 or a malformed hour range fails the whole call with a `validation` error that has no `path`. In [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>), this means no operation from that [change set](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>) is applied.
:::

So a change set like this one applies nothing, not even the valid `duration` update in front of the bad one:

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

const project = new JsonProject("{}");
project.solve(
  JSON.stringify({ tasks: [{ id: 1, start_date: "2026-01-05", duration: 2 }] }),
  "{}",
);

const changes = {
  tasks: { update: [{ id: 1, duration: 3 }, { id: 1, priority: 5000 }] },
};
console.log(project.apply(JSON.stringify(changes)));
// {"error":{"code":null,"kind":"validation","message":"priority must be in 0..1000","path":null},"ok":false}
project.free();
```

### A rejected replacement keeps the old state

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

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

const leaf = (id, duration) => ({ id, start_date: "2026-01-05 08:00", duration });

const project = new JsonProject("{}");
const first = project.solve(JSON.stringify({ tasks: [leaf(1, 2)] }), "{}");

const bad = JSON.parse(project.solve(JSON.stringify({ tasks: [leaf(2, -1)] }), "{}"));
console.log(bad.error); // { kind: "validation", path: "tasks[0].duration", ... }

// Still the first data set: the cached result comes back.
const again = project.solve(undefined, "{}");
console.log(again === first); // true
project.free();
```

## Project errors

A `project` error means the call came at the wrong moment. Each `code` has one recovery:

| `code` | Returned by | Recovery |
|---|---|---|
| `no-baseline` | [`preview`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-preview.md>), [`loadProfile`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-load-profile.md>), [`evaluate`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-evaluate.md>), [`slots`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-slots.md>) and [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md>) on a project with no solved baseline, including right after [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>) | Call [`solve`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) first. After `apply`, call `solve(undefined, ...)` before any of these. |
| `stale-baseline` | Any call given a `baselineId` (in its options or in [`SchedulingChanges`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-changes.md>)) that doesn't name the current baseline | The project was solved, edited or leveled since you read the token. Take `baselineId` from the latest result and retry. |
| `needs-resync` | `solve` without data, `apply` and [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>) after an `apply` rejected an operation | Send the full data again with `solve(dataJson, ...)`. Nothing else is accepted until that call succeeds. |

### Telling the codes apart

Branch on `code` for project errors and fall back to `kind` and `path` for everything else:

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function previewOrExplain(project, changes, baselineId) {
  const envelope = JSON.parse(
    project.preview(JSON.stringify(changes), JSON.stringify({ baselineId })),
  );
  if (envelope.ok) return `${envelope.result.updates.length} tasks would move`;

  const { kind, code, message, path } = envelope.error;
  switch (code) {
    case "no-baseline":
      return "solve the project first";
    case "stale-baseline":
      return "the plan changed since you loaded it; refresh and try again";
    case "needs-resync":
      return "reload the full project";
  }
  return `invalid change at ${path ?? "<input>"}: ${kind}: ${message}`;
}

const project = new JsonProject("{}");
// An unsolved project: prints "solve the project first".
console.log(previewOrExplain(project, {}, "none"));
project.free();
```

[`preview`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-preview.md>) checks both `options.baselineId` and `changes.baselineId`. Baseline tokens are opaque and can differ between runs for the same data, so never store them across processes or compare them in tests.

### Recovering from `needs-resync`

A rejected operation in [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>) isn't an error envelope. The call returns `ok: true`, lists the operation in [`rejected`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md#applyreport>) and sets `needsResync`. From then on, [`solve`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) without data, `apply` and [`level`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-level.md>) fail with `needs-resync`, because your model and the project's model no longer agree. Resync with the full data you hold:

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function commit(project, changes, sourceOfTruth) {
  const report = JSON.parse(project.apply(JSON.stringify(changes)));
  if (!report.ok) return report;
  if (report.result.needsResync) {
    for (const { path, message } of report.result.rejected) {
      console.error(`rejected ${path}: ${message}`);
    }
    return JSON.parse(project.solve(JSON.stringify(sourceOfTruth), "{}"));
  }
  return JSON.parse(project.solve(undefined, "{}"));
}

const data = { tasks: [{ id: 1, start_date: "2026-01-05 08:00", duration: 2 }] };
const project = new JsonProject("{}");
project.solve(JSON.stringify(data), "{}");

// Task 999 doesn't exist: the operation is rejected.
const changes = { tasks: { update: [{ id: 999, duration: 3 }] } };
const envelope = commit(project, changes, data); // logs "rejected tasks.update[0]: unknown task id"
if (envelope.ok) console.log(`resynced, baseline ${envelope.result.baselineId}`);
project.free();
```

If the resync data itself fails validation, the project stays in the `needs-resync` state. Fix the data and call `solve` with it again. The [recovery how-to](<https://docs.dhtmlx.com/planning-engine/wasm/how-to/recover-from-rejected-changes.md>) covers this flow in an editor.

## Results that are not errors

These all come back with `ok: true`. Check them on every result you show to a user:

| Where | What it means |
|---|---|
| [`cycles`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#cycle>) | Dependency cycles. A cyclic solve returns unchanged dates and empty `updates`, and still installs a baseline. |
| [`conflicts`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#conflict>) | `constraint-violation` (a link and a hard constraint disagree, the constraint wins) and `calendar-non-working` |
| [`notes`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#note>) | Input the engine dropped (`dropped-link`, `dropped-task`, `dropped-assignment`) or fixed (`repaired`) |
| [`converged`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md>) | `false` when the planner didn't reach a fixed point within [`config.max_iterations`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>). The dates are still valid output. |
| [`rejected`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md#applyreport>) in the `apply` report | Operations that `apply` refused. The other operations in the change set were still applied. |
| [`candidates[i].error`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-evaluate.md#candidateresult>) in the `evaluate` result | A candidate whose change set was rejected, as `{path, message}`. That candidate has no metrics; the other candidates are scored normally. |
| [`unfilled` and `invalid`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md#suggestresult>) in the `suggestAssignments` result | Targeted slots left empty, and slots whose final resource is outside `allowed` |

### Checking a schedule for problems

```js
const { schedule } = require("./pkg-node/plen_wasm.js");

const data = {
  tasks: [
    { id: "a", start_date: "2026-01-05 08:00", duration: 1 },
    { id: "b", start_date: "2026-01-05 08:00", duration: 1 },
  ],
  // a -> b -> a: a dependency cycle, reported as data.
  links: [
    { id: "l1", source: "a", target: "b" },
    { id: "l2", source: "b", target: "a" },
  ],
};

const envelope = JSON.parse(schedule(JSON.stringify(data), "{}"));
if (!envelope.ok) throw new Error(envelope.error.message);
const result = envelope.result;

for (const cycle of result.cycles) {
  console.log(`cycle through tasks ${cycle.tasks.join(", ")}`);
}
for (const conflict of result.conflicts) {
  if (conflict.kind === "constraint-violation") {
    console.log(`task ${conflict.task}: required ${conflict.required}, got ${conflict.actual}`);
  }
}
if (!result.converged) console.log("planner hit max_iterations");
```

The cycle comes back as data:

```json
{ "ok": true, "result": { "cycles": [{ "tasks": ["a", "b"], "links": ["l1", "l2"] }], "updates": [], "conflicts": [], "notes": [], "converged": true, "baselineId": "1-7dbb70640f563c2c" } }
```

Dependency cycles are accepted state. Cycles in the `parent` hierarchy are different: they're invalid input and fail with a `validation` error.

## Repair instead of reject

Strict validation suits data your own code produces. For data from a UI or an older store, create the project with [`{"validation": "repair"}`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/common.md#validation-mode>). It coerces invalid data instead of failing:

- 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 `repaired` [note](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/schedule-result.md#note>) with `path` and `message`, so you can show the user what changed. Repair mode still rejects an entirely undated project, and other problems still return a `validation` error. Repair is a project option, so it isn't available through the one-shot [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>), which always validates strictly.

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

const data = {
  tasks: [
    { id: 1, start_date: "2026-01-05 08:00", duration: 1 },
    { id: 1, start_date: "2026-01-05 08:00", duration: 2 },
  ],
};

const project = new JsonProject(JSON.stringify({ validation: "repair" }));
const envelope = JSON.parse(project.solve(JSON.stringify(data), "{}"));
if (envelope.ok) {
  for (const note of envelope.result.notes) {
    if (note.kind === "repaired") console.log(`repaired ${note.path}: ${note.message}`);
  }
}
// repaired tasks[1].id: duplicate id replaced the earlier record, retaining its position
project.free();
```

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

## Errors from slots

The skill layer has no error type of its own on the wire. [`slots`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-slots.md>) returns the same envelope as every other call:

- A bad [pool](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-slots.md#pool>) or [requirement](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-slots.md#requirement>) is a `validation` error with a path into the arguments: `pool.workers[0].resource`, `requirements[2].count`, `requirements[1].assignments[0]`.
- A project with no clean solved baseline returns `project` / `no-baseline`. Call [`solve`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-solve.md>) before `slots`, including after [`apply`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-apply.md>).
- A malformed pool or requirement (a missing `matches`, an unknown field) is a `parse` error with `path: null`.

```json
{ "ok": false, "error": { "kind": "validation", "code": null, "message": "unknown resource \"ghost\"", "path": "pool.workers[0].resource" } }
```

Because `slots` and [`suggestAssignments`](<https://docs.dhtmlx.com/planning-engine/wasm/api/project-suggest-assignments.md>) share one envelope format, one handler covers both:

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

const pool = { workers: [{ resource: "dev1", skills: [{ id: "rust" }] }] };
const requirements = [{ id: "backend", task: 1, matches: [{ skill: "rust" }] }];

function staff(project) {
  const slots = JSON.parse(
    project.slots(JSON.stringify(pool), JSON.stringify(requirements)),
  );
  if (!slots.ok) return slots;
  return JSON.parse(project.suggestAssignments(JSON.stringify(slots.result), "{}"));
}

// An unsolved project: slots fails with no-baseline.
const project = new JsonProject("{}");
const envelope = staff(project);
if (envelope.ok) {
  console.log(`${envelope.result.steps.length} steps`);
} else if (envelope.error.kind === "project") {
  console.error(`project state: ${envelope.error.code}`);
} else {
  console.error(`${envelope.error.path ?? "<input>"}: ${envelope.error.message}`);
}
project.free();
```

## Host-level throws

A few failures happen outside the envelope and reach your host as a thrown value. They come from the boundary itself, not from the data:

| Failure | What you get |
|---|---|
| [`new JsonProject(optionsJson)`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#creating>) with bad options | A thrown **string**: the error envelope text, not an `Error` |
| Any method on a handle after [`free()`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#free>) | A thrown `Error` from the bindings (`null pointer passed to rust`) |
| [`init()`](<https://docs.dhtmlx.com/planning-engine/wasm/api/init.md>) can't fetch or instantiate the module (browser build) | The promise rejects. Calling exports before `init()` resolves fails too. |
| An internal engine failure | A `WebAssembly.RuntimeError` (a wasm trap) |

The constructor is the only export that throws for bad input. Catch the string and parse it like any other envelope:

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function createProject(options) {
  try {
    return new JsonProject(JSON.stringify(options));
  } catch (thrown) {
    // The constructor throws the error envelope as a string, not an Error.
    const { error } = JSON.parse(thrown);
    throw new Error(`${error.kind}: ${error.message}`);
  }
}

try {
  createProject({ validation: "loose" });
} catch (e) {
  console.error(e.message); // "validation: unknown validation mode"
}
```

To avoid use after `free()`, free each handle exactly once, or let `using` do it through [`[Symbol.dispose]`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#symboldispose>). Each [`clone()`](<https://docs.dhtmlx.com/planning-engine/wasm/api/json-project.md#clone>) is a separate handle. [Module and handles](<https://docs.dhtmlx.com/planning-engine/wasm/module-and-handles.md>) covers handle lifetime and instantiation.

In the browser build, await `init()` before the first `new JsonProject` or [`schedule`](<https://docs.dhtmlx.com/planning-engine/wasm/api/schedule.md>) call, and handle its rejection:

```js
import init, { schedule } from "./pkg/plen_wasm.js";

try {
  await init();
} catch (e) {
  console.error("could not load the WebAssembly module", e);
  throw e;
}
console.log(schedule(JSON.stringify({ tasks: [] }), "{}"));
```

### Engine failures

Valid input doesn't normally crash the engine. There's one known exception: in block mode ([`move_projects: true`](<https://docs.dhtmlx.com/planning-engine/wasm/api/types/scheduling-config.md>)), chained summary→summary links combined with leaf links that cross the same summaries push dates further with every group. On large chains (about 50 groups, 200 tasks) the date arithmetic overflows and surfaces as a trap.

:::note
An internal engine failure surfaces as a `WebAssembly.RuntimeError`; after one, create the project again (or reload the module) before continuing.
:::

```js
const { JsonProject } = require("./pkg-node/plen_wasm.js");

function solveSafely(project, dataJson) {
  try {
    return { project, text: project.solve(dataJson, "{}") };
  } catch (e) {
    if (!(e instanceof WebAssembly.RuntimeError)) throw e;
    // Don't reuse the old handle after a trap.
    return { project: new JsonProject("{}"), text: null };
  }
}
```

A trap isn't an envelope, so a host that forwards envelope text unparsed still needs a `try`/`catch` around the call.
