Richer Op Model — Retract Operations and Per-Cell Typed CRDT Merge
Accepted
Context
ADR-027 declares per-domain CRDT merge strategies (HlcLastWriterWins, OrSet, TextMerge, GSet) as load-bearing, and ontoref-sync ships them as tested `CrdtMergeStrategy` impls. bl-018 wired multi-actor convergence, but only at the commit-layer cell level via HlcLastWriterWins: the highest-HLC assertion to a cell wins. Investigation while attempting bl-029 found the substrate cannot express the other three strategies:
- `OpPayload` has only `EntityAssert` (and the state-neutral marker variants). There is NO retract/remove — so OR-Set add/remove cannot be represented at all. - The commit layer stores opaque value bytes per cell and resolves concurrent writes by last-writer-wins. There is no place for a cell to declare a richer merge (set union, text merge), so OrSet/TextMerge have nothing to bind to. - Grow-only/append-only domains (GSet-like) are already covered by the distinct-entity cell model and need no new machinery.
Without a richer op model the four CRDT strategies remain library code no domain can use; convergence is correct only where last-writer-wins is the right semantics.
Decision
ACCEPTED. Extend the substrate op model along two axes, both behind the existing tier-2/`substrate` posture (tier-0/1 unaffected):
1. RETRACT OP. Add `OpPayload::Retract { attrs, entity }` — the inverse of `EntityAssert`, removing the named (entity, attribute) cells. A retract is a witnessed, signed, DAG-anchored op like any mutation; it is the precondition for remove-based domains (OR-Set).
2. PER-CELL TYPED MERGE. A cell resolves its value through a CRDT type selected by the cell's DOMAIN (derived from the attribute's declared domain in the catalog), not by unconditional last-writer-wins. The commit layer's replay FOLDS every op touching a cell through that domain's `CrdtMergeStrategy` rather than taking the last applied value. `HlcLastWriterWins` remains the DEFAULT for any cell whose domain declares no strategy, so the current behaviour and all existing state roots are preserved exactly.
The fold preserves determinism: CRDT merges are commutative, associative, and idempotent, and ties resolve by the same HLC+payload order the oplog already imposes — so the rebuilt state root is independent of arrival order, which bl-016's replay determinism and bl-018's convergence both require. Witnesses are unchanged: a Retract is witnessed exactly as an EntityAssert is, keeping every mutation on the witness-as-axis-seam.
Constraints
- Hard A Retract operation MUST be a signed, DAG-anchored oplog entry subject to the same append-time validation (signature, parents, monotonic HLC) as EntityAssert. Deletion MUST NOT have an unwitnessed side channel.
- Hard The per-cell merge MUST default to HlcLastWriterWins for any cell whose domain declares no CRDT strategy, so state roots computed before this ADR are reproduced exactly. The fold MUST be commutative, associative, and idempotent for every strategy.
Alternatives considered
- Keep cell-LWW only; never wire the richer strategies — rejected: Leaves OrSet/TextMerge as dead library code and convergence correct only for LWW domains — contradicting ADR-027, which makes per-domain CRDT load-bearing.
- Model deletion as an EntityAssert of a tombstone sentinel value — rejected: Overloads the value space (a real value could collide with the sentinel) and still gives LWW semantics, not add-wins. A typed Retract is unambiguous and composes with the fold.
- Per-domain merge in a layer above the commit layer (post-process the root) — rejected: The state root must be the merge result; post-processing it would make the witnessed root and the merged state diverge — exactly the seam tear ADR-044 and bl-018 guard against. The merge must be the commit-layer reduce itself.