ADR 0009 — Durable writes are CommitCommands, and dispatch() refuses them

Status: accepted · Amends: ADR 0004 — supersedes the open question it left · Amended by: ADR 0012 — transaction membership is an explicit handle

Context

Blaeu had two pipelines by design: a synchronous one for pointer events, and an asynchronous one for writes, where a validation rule may veto and a preset's middleware may rewrite what lands. ADR 0004 argued for that split and still does.

The asynchronous half was never connected.

BlaeuMap constructed an AsyncCommitPipeline. It registered the preset's commit middleware into it. ValidationRegistry.asCommitMiddleware() installed itself into it. BlaeuMap.debug exposed it. And no code path in the kernel ever called pipeline.run(). The only production caller in the entire repository was preset-game's EntitySession.place(), which built its own CommitContext by hand — a local workaround for a kernel gap, which is precisely why the gap survived: the one place that needed the pipeline had quietly stopped needing the kernel.

The consequences were not subtle, and none of them were visible as a failing test:

The cause was a genuine design tension, not carelessness. CommandBus.dispatch() is synchronous — it runs on pointermove, up to 120 times a second — and the commit pipeline is asynchronous, because a real cadastral overlap check is a round-trip to a parcel registry. Something had to give.

Decision

Split the write path in two, and make the type system enforce which one you are on.

A command that writes durable features implements CommitCommand, which adds two members to Command:

interface CommitCommand<R = void> extends Command<R> {
  /** What is about to be written — real ids, geometry already normalised. */
  intent(ctx: CommandContext): CommitIntent
  /** Take what the middleware chain produced, and write *that*. */
  adopt(features: readonly BlaeuFeature[]): void
}

and the bus grows an asynchronous counterpart:

dispatch<R>(command: Command<R> & { intent?: never }): DispatchResult<R>   // sync, no pipeline
commit<R>(command: CommitCommand<R>): Promise<DispatchResult<R>>           // async, pipeline
commitTransaction(label: string, fn: (tx: CommitTransaction) => Promise<void>): Promise<DispatchResult<void>>

The tx handle was added by ADR 0012; children must be submitted through it, not through the bus.

The intent?: never is the load-bearing part. dispatch() will not compile if you hand it a feature-writing command, and it throws at runtime for JavaScript callers. If the way to skip validation were "call the other method", then skipping validation would be one typo away, and every product built on this library would eventually ship a write path that no rule guards. That is the bug we just spent a day finding; we are not leaving the door open behind us.

CommitIntent.features are materialised, not written: FeatureStore.materialise() mints ids, stamps meta, winds rings and quantises coordinates to the working CRS's grid, and writes nothing. A rule therefore judges the parcel that will actually exist. A rule that passed on the raw input but would have failed on the stored feature is not a weak rule — it is a lie.

What stays on dispatch(), and why that is not a loophole

Not every store write should be validated. A drag preview, a vertex handle, a snap indicator, a hover highlight — these live in the store because that is where the renderer reads from, and running a JSTS topology check on them at 120 Hz would be both slow and wrong: geometry is legitimately invalid halfway through a drag. Those stay synchronous, transient, and unvalidated.

The rule of thumb is if it survives the gesture, it commits. A rubber band does not. A parcel does.

Alternatives rejected

Let the kernel infer which writes to validate, instead of an opt-in interface. It reads as the friendlier design — no interface to implement, nothing for a plugin author to remember. It is rejected by the paragraph above: a drag preview, a vertex handle and a snap indicator are all store writes, and running a JSTS topology check on them at 120 Hz would be both slow and wrong, because geometry is legitimately invalid halfway through a drag. Any inference rule the kernel could apply would be a guess about intent that the command already knows for certain. The same reasoning is written on CommitCommand itself, in packages/core/src/types/command.ts.

Keep one dispatch() and make the commit path a convention. Cheapest to implement, and it is the design we were already living with. Rejected because skipping validation would then be one typo away, on a code path nobody reviews twice, in every product built on this library. The intent?: never makes it a compile error instead — a guarantee is only worth what its weakest caller can bypass.

Validate the command's raw input rather than the materialised feature. Simpler: no materialise(), no minting ids for something that may never be stored. Rejected because a rule that passed on the raw input but would have failed on the stored feature is not a weak rule — it is a lie. The quantised, wound, meta-stamped parcel is the one that goes on the deed, so that is the one a rule must judge.

Consequences

The test that would have caught it

Not one that constructs a middleware and calls it. One that goes through the public API and asserts on map.store — the only component that cannot lie about whether the write happened.

packages/core/src/commands/commit.test.ts.

Edit this page on GitHub — this site is generated from docs/adr/0009-commit-commands.md, which is the source of truth.