How a refusal reaches the caller
A beforeChange handler says no. Core writes nothing. It raises one Error report. It throws
MutationCancelledError back to the call that started the write.
The Dataset door
This is the path a programmatic write takes when a beforeChange handler refuses it. The caller
is dataset.entries.update, or any other mutator that ends in commitChangeSet. Undo and redo
use the same tail.
Derived from data/transaction.ts, data/error-reporting.ts, data/event-bus.ts,
model/error-report.ts, model/errors.ts, api/watch-all-errors.ts,
api/attempt-mutation.ts.
beforeChange veto inside commitChangeSet. Coral is the
refuse() call. The open dashed arrow is the error event. The filled
dashed arrow back to the caller is the throw.
dataset.on('beforeChange', ({ refuse }) => refuse('t1 is locked.'));
dataset.entries.update('t1', { name: 'Framing' });
// throws MutationCancelledError
// Dataset 'error' fires when something is subscribed
Each step
Time runs down the diagram. The store never applies the changeset. The two outputs are the throw and, when a listener exists, the Error report.
Derived from data/transaction.ts, data/error-reporting.ts, data/event-bus.ts,
model/errors.ts.
| Step | What runs | What it does |
|---|---|---|
ENTRIES.UPDATE | entries.update → runTransaction → commitChangeSet | The body stages the edit. The extension hook and the Rollup run. Core builds one changeset, then asks beforeChange. |
BEFORECHANGE | bus.emit('beforeChange', { changeSet, refuse }) | Core makes one RefusalNote per emit and puts refuse on the payload. Every handler runs. A veto from one handler does not skip the rest. |
REFUSE(REASON) | refuse('t1 is locked.') | The call writes the words onto the note and returns false. The first reason wins. Two handlers never join their sentences. A bare false still refuses, with no reason. |
RETURN FALSE | emit → false | Core discards the write set. Nothing is stored. No change event fires. |
BUILD REPORT | buildRefusalReport + MutationCancelledError | One builder, in data/error-reporting.ts. For this door the message is the thrown error's own wording. Severity is info. by is consumer. |
EMIT ERROR | raiseErrorOn(dataset.bus, report) | The report reaches a listener on the Dataset error event. If nobody listens, this step does nothing. This site has no console fallback. A Gantt does not forward Dataset reports — two Gantts on one Dataset would each hear the same report twice. Call watchAllErrors([dataset, gantt], …) to hear both buses. |
THROW CANCEL | throw MutationCancelledError | This output always happens. The error carries the refused changeset and the reason. attemptMutation(() => …) catches it and returns false, so a button does not need its own try/catch. Any other error still throws. |
The other doors
Three vetoes share buildRefusalReport. The cell editor raises its own report. A silent
canWrite refusal is not this flow: no handle paints, and no report fires.
Derived from view/gesture-pipeline.ts, view/live-region.ts, view/capability.ts,
extensions/features/inline-editing.ts, api/gantt.ts.
| Door | Who says no | Throw | Report bus | Live region |
|---|---|---|---|---|
beforeChange | a Dataset handler | MutationCancelledError | Dataset error | no — subscribe with watchAllErrors |
beforeEntryMove / beforeEntryResize | a Gantt handler | none — the pipeline restores the preview | Gantt error, codes entry-move-cancelled / entry-resize-cancelled | yes — LiveRegion is already subscribed |
row or bar Delete | the remove rule (a locked Entry, or a parent that holds one) | none — the command removes nothing | Gantt error, code entry-remove-refused | yes — same Gantt bus |
| "Clear dates" | a lock, a writable rule, or beforeChange | none — the command writes nothing | Gantt error, code entry-clear-dates-refused | yes — same Gantt bus |
| vertical drop | a rule (reorder, a lock rule, a place rule) | none — the drop writes nothing | Gantt error, code entry-drop-refused, raised on release | yes — same Gantt bus |
| cell editor | the inlineEditing() plugin | none — the editor stays open, or a notice sits on the cell | Gantt error, plugin codes such as derived-value | yes — same Gantt bus |
:::note A refused gesture commit is one record, not two
The pipeline reports a silent beforeEntryMove / beforeEntryResize veto. When the gesture
passes that gate and then beforeChange refuses the write, data/transaction.ts already raised
the Dataset report. commitEntryEdits returns false, and the pipeline does not raise a second
one.
:::
A drag that a beforeEntryMove handler refuses uses the same refuse(reason) call. Core builds
the sentence itself, because that door has no thrown error to quote:
gantt.on('beforeEntryMove', (move) =>
move.shiftsTime && move.start < mobilization
? move.refuse('The drop is before mobilization.')
: undefined,
);
The report message is Nothing was saved. A beforeEntryMove handler refused this move and said: "…". A bare false drops the and said clause.
What the report carries
An Error report is the record a consumer subscribes to. A FreeGanttError is the class a
consumer catches. They are two different things. Core raises reports and keeps none.
Derived from model/error-report.ts, data/error-reporting.ts, view/live-region.ts,
api/watch-all-errors.ts, harness/main.ts, harness/e2e/editing.ts, harness/e2e/data.ts.
| Field | On a beforeChange refusal |
|---|---|
at | stamped by the raiser from time/'s now() |
code | mutation-cancelled |
message | the thrown error's sentence, including the handler's words when they called refuse |
severity | info — the library said no on purpose |
by | consumer — the bus saw a handler return false |
reason | the handler's own words, or absent after a bare false |
cause | the MutationCancelledError, which still holds the changeset |
LiveRegion reads the Gantt error event and copies report.message into a polite live region
for info and error. It stays silent on warning. A Dataset refusal never reaches that node on
its own.
The harness demos call watchAllErrors([dataset, gantt], …) and log each report. That is how a
Dataset refusal becomes a line in the demo log.