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 and the JsonProject methods except clone and free) returns JSON text, and that text is always an envelope:

{ "ok": true, "result": { "updates": [], "cycles": [], "conflicts": [], "notes": [], "converged": true } }
{ "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() is the one async step, and it's covered in 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.

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

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() has resolved. If your host only forwards the envelope, 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", a solve with data or a schedule 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, TaskData and AssignmentData.

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, this means no operation from that change set is applied.

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

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, a solve with data that fails validation installs nothing. The project keeps its accepted baseline, and solve without data still returns the result you had before:

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, loadProfile, evaluate, slots and suggestAssignments on a project with no solved baseline, including right after apply Call solve first. After apply, call solve(undefined, ...) before any of these.
stale-baseline Any call given a baselineId (in its options or in SchedulingChanges) 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 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:

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 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 isn't an error envelope. The call returns ok: true, lists the operation in rejected and sets needsResync. From then on, solve without data, apply and level fail with needs-resync, because your model and the project's model no longer agree. Resync with the full data you hold:

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 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 Dependency cycles. A cyclic solve returns unchanged dates and empty updates, and still installs a baseline.
conflicts constraint-violation (a link and a hard constraint disagree, the constraint wins) and calendar-non-working
notes Input the engine dropped (dropped-link, dropped-task, dropped-assignment) or fixed (repaired)
converged false when the planner didn't reach a fixed point within config.max_iterations. The dates are still valid output.
rejected in the apply report Operations that apply refused. The other operations in the change set were still applied.
candidates[i].error 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 in the suggestAssignments result Targeted slots left empty, and slots whose final resource is outside allowed

Checking a schedule for problems#

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:

{ "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"}. 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 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, which always validates strictly.

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 shows the repair flow end to end.

Errors from slots#

The skill layer has no error type of its own on the wire. slots returns the same envelope as every other call:

  • A bad pool or 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 before slots, including after apply.
  • A malformed pool or requirement (a missing matches, an unknown field) is a parse error with path: null.
{ "ok": false, "error": { "kind": "validation", "code": null, "message": "unknown resource \"ghost\"", "path": "pool.workers[0].resource" } }

Because slots and suggestAssignments share one envelope format, one handler covers both:

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) with bad options A thrown string: the error envelope text, not an Error
Any method on a handle after free() A thrown Error from the bindings (null pointer passed to rust)
init() 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:

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]. Each clone() is a separate handle. Module and handles covers handle lifetime and instantiation.

In the browser build, await init() before the first new JsonProject or schedule call, and handle its rejection:

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), 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.

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.