Hard rules

Commit-time invariants the engine refuses to negotiate on.

Most editor bugs trace back to a shortcut taken at a layer boundary: a plugin mutates a document in a subscriber callback, a step reaches into window, or DOM evidence becomes semantic truth. These six rules are the engine's commit-time invariants. They are not every project rule; contribution workflow, docs, security, support, and TSDoc expectations live in the contributing and governance docs.

1. Semantic edits enter as intents

Meaningful edits go through engine.dispatch(intent). Tests, the substrate, command handlers, and plugin commands all produce the same intent shape. Undo and redo restore recorded history entries, but new edit behavior still belongs in the intent and planner vocabulary — do not reach around the pipeline.

2. Steps are pure

step.apply(doc) returns a new doc and a Mapping. step.invert(docBefore) returns the inverse step. Neither reads globals, touches the DOM, or asks the wall clock. This is what makes transactions replayable and traces deterministic.

3. Transactions have a stable JSON form

Live Step objects expose methods such as apply, invert, and map, but their durable data shape is serializable through transactionToJSON(...). No closures, DOM references, or host objects belong in transaction data. This is what lets traces go to disk, to a server, and back.

4. The DOM is a projection

EditorState is the source of truth. The renderer reads state and produces DOM; browser events can produce intents, but the DOM never mutates semantic state directly. Selection lives in the model; the browser's DOM Range is a rendered view of the model selection. See Selection mapping.

5. Normalization is idempotent and convergent

normalize(normalize(doc)) equals normalize(doc). Rules run in deterministic order until they reach a fixed point, with a pass limit to catch broken rule sets. Failure to converge throws NormalizationDidNotConvergeError — the engine does not paper over it.

6. Commits are atomic

Either every step applies, normalization converges, and subscribers are notified with the new state — or nothing is observable. A partially-applied transaction is never a visible state.

Why these are "hard"

Each rule, violated, breaks something that cannot be patched at the edge. Reach past the intent layer and trace replay no longer reproduces the bug. Let a step touch the DOM and apply stops being deterministic. Put non-serializable data in a transaction and persistence silently drops fields.

When a rule gets in the way, the fix is to widen the intent vocabulary, add a planner rule, or add a normalizer rule — not to carve out an exception in the engine.

  • Runtime code must preserve the same semantic/runtime boundary: browser evidence can produce intents, but cannot become authority.
  • Public API changes need matching TSDoc.
  • Behavior changes need matching tests and docs.
  • README files stay short; broad explanations belong in these docs.