Audit — Tabia merge under ACI
Addendum (2026-05-16, after design v0.3): The architectural framing has shifted since this audit was written. Tabia is a 1:1 game library; OpenFile is a many:1 wrapper. Tabia’s existing
mergeGamesserves a 1:1 use case (one user combining local PGN files), where the ACI gaps documented below produce visible artifacts across hypothetical re-runs with different input orderings — but a single user runs the merge once, sees one result, and the non-determinism is non-impacting. Tabia’s mergeGames is no longer proposed to be refactored. It stays as-is.The findings below stand as an accurate description of mergeGames’ properties. They are not load-bearing for a Tabia refactor. ACI matters for OpenFile’s own mergeGames-equivalent (a separate implementation built on the op vocabulary; used to bootstrap collaborative sessions from multiple PGN sources), which is ACI-correct by construction. See SPEC-op-vocabulary.md §7 for how each gap below is resolved in OpenFile’s implementation.
Sections “Recommendation” and “Suggested sequence” near the end of this document propose refactoring Tabia. Those recommendations are superseded. Read them as historical context for how the design evolved, not as current direction.
Status: initial audit, kickoff design phase (recommendations superseded by v0.3)
Subject: PROMOTE/Tabia/src/games.js — mergeGames, mergeMoves, unifyMove, finalizeNags, supporting helpers
Purpose: determine whether Tabia’s existing merge satisfies ACI
(associativity, commutativity, idempotence), or what would need to change
for OpenFile to wrap it as an op-based CRDT.
Why we’re auditing the function, not the environment
Section titled “Why we’re auditing the function, not the environment”OpenFile and Tabia operate in different domains: Tabia’s merge consumes local, fully-present, batch-shaped input (an array of PGN strings). OpenFile delivers ops over the network, with all the standard hazards (reordering, duplication, partial arrival, concurrent writes).
It is tempting to conclude that an audit of the batch merge tells us
nothing about the streaming case. That is half right. The audit cannot
tell us how the merge behaves under network conditions, because the
batch merge never encounters them. But ACI is a property of the merge
function, not the environment. If the function is non-commutative in a
clean local test (mergeGames([A, B]) ≠ mergeGames([B, A])), no
transport layer can save it: reordering is normal in distributed
systems, and OpenFile would inherit the divergence.
The audit therefore has two goals:
- Find ACI gaps — places where the current merge produces different results under input reordering, duplication, or regrouping.
- Find batch-only patterns — places where the merge depends on
whole-input visibility (
selectPrimary,finalizeNags,mergeHeaders) and therefore won’t decompose cleanly into discrete streaming ops.
The output of (1) is a defect inventory. The output of (2) is a refactor sketch. Together they answer: can OpenFile wrap Tabia, or does Tabia need to be re-shaped first?
ACI gap inventory
Section titled “ACI gap inventory”Each entry: what breaks → where → fix sketch.
G1. selectPrimary privileges input order on ties — commutativity break
Section titled “G1. selectPrimary privileges input order on ties — commutativity break”games.js:441-462. When two
sources tie on mainline length, header completeness, and annotation
density, compareForPrimary returns 0 and selectPrimary keeps the
earlier index. The “primary” source’s mainline drives the merged tree’s
mainline; the others’ divergent continuations become variations. Swap
input order → swap which mainline is “the” mainline → different
structural shape, not just different metadata.
Fix sketch. Replace the input-order tiebreaker with a stable content-derived one: hash of mainline UCI string, or annotator-handle ordering. Better: drop the privileged-primary concept entirely in the streaming model (see §G2).
G2. Mainline-vs-variation assignment is asymmetric by construction — commutativity break
Section titled “G2. Mainline-vs-variation assignment is asymmetric by construction — commutativity break”games.js:300-321. When mainlines diverge, “primary’s mainline stays primary; the other’s continuation grafts as a variation.” This is asymmetric by design, not by accident. The same two sources produce structurally different trees depending on which one was chosen as primary.
This is the load-bearing batch-only pattern. There is no concept of “the primary source” in a streaming world — sources arrive at their own times and the first-arriving one would be artificially privileged.
Fix sketch. Three options for a streaming-compatible model:
- No privileged mainline. Both diverging continuations become variations under a common synthetic parent. The mainline is empty or the shared prefix only. Rendering picks a “display mainline” by policy (longest, primary-by-handle, app preference) at read time, not at write time.
- First-mover-wins by causal timestamp. Whoever’s continuation arrived first at the canonical replica becomes the mainline. Requires a canonical replica; doesn’t work P2P.
- Mainline ownership is a separate op. A
setMainline(nodeId)op can be issued; concurrentsetMainlineops resolve by last-write-wins on Lamport timestamp, with the loser’s claim preserved as an annotation.
Probably option 1. Mainline becomes a render-time policy, not a data-layer fact, which decouples the CRDT shape from the display choice.
G3. Comment segments append by merge order — commutativity break
Section titled “G3. Comment segments append by merge order — commutativity break”games.js:364. unifyMove does
target.comment_segments.push(...source.comment_segments). The
resulting segment array order depends on the order
mergeGames iterates the others array, which preserves input
order. Reorder the inputs → reorder the segments in the output.
Fix sketch. Each segment carries (annotator, sequenceNumber) or
(annotator, lamport). Segments are stored as a sorted multiset keyed
by that tuple. Read-time render iterates in canonical order.
G4. Variation siblings append by merge order — commutativity break
Section titled “G4. Variation siblings append by merge order — commutativity break”games.js:286-293 and
games.js:312-320. Both code paths
that produce sibling variations do target.variations.push(...).
Sibling order reflects merge order.
Fix sketch. Same as G3 — store as sorted multiset keyed by
(variationOwner, lamport). For the renderer, a stable per-node
sibling order is what matters; the actual ordering policy can be
content-derived or handle-derived as long as it’s deterministic.
G5. Legacy comment field is order-dependent concatenation — commutativity break
Section titled “G5. Legacy comment field is order-dependent concatenation — commutativity break”games.js:366-372. The legacy
comment (joined string) is maintained for back-compat by appending
new sources’ prose. Append order = merge order.
Fix sketch. Derive comment from comment_segments at read time,
not write time. The legacy field becomes a getter, not a stored value.
This eliminates the violation by construction.
G6. unifyMove scalar annotations are last-write-wins by merge order — commutativity break
Section titled “G6. unifyMove scalar annotations are last-write-wins by merge order — commutativity break”games.js:381-392. Drawables
(arrays) union with dedup — good, commutative. But scalars like eval
and clk overwrite: target.annotations[key] = value. Last source to
be merged wins; reorder inputs → different scalar values.
Fix sketch. Scalars need either (a) domain-specific resolution (“the highest engine-eval depth wins, breaking ties by Lamport”), or (b) per-author scalars where the data layer keeps all contributions and the renderer picks. (a) is simpler for stable resolution; (b) is more faithful to the attribution-everywhere principle.
G7. finalizeNags “no conflict” branch picks first annotator’s array — minor commutativity break
Section titled “G7. finalizeNags “no conflict” branch picks first annotator’s array — minor commutativity break”games.js:415-416. When all
annotators agree on the NAG set, move.nags = bySource[annotators[0]]
— the array order reflects whichever annotator was iterated first
(Object.keys order ≈ insertion order in V8). Same set, different
array order → different move.nags output.
Fix sketch. Sort NAGs canonically (numeric ascending) before writing. Cheap; eliminates the issue.
G8. mergeHeaders placeholder fallthrough is order-dependent — commutativity break
Section titled “G8. mergeHeaders placeholder fallthrough is order-dependent — commutativity break”games.js:497-506. When the primary’s
header is a placeholder, the fallback walks others in input order and
picks the first non-placeholder. Reorder the inputs → different
fallback winner if multiple secondaries have non-placeholder values.
Fix sketch. Sort others by stable content-derived key
(annotator handle, header value hash) before fallthrough. Or move
header merging out of the data layer entirely — headers are app-level
metadata, arguably not part of OpenFile’s op set at all.
G9. mergeGames([A, A]) is not idempotent — idempotence break
Section titled “G9. mergeGames([A, A]) is not idempotent — idempotence break”The most consequential gap. Walking through what happens when the same PGN appears twice:
preAttributeruns on both copies. Both producecomment_segmentswrapping identical comments with the same annotator.mergeMovesruns the second copy against the first (which is now the primary). At each matching node,unifyMoveis called.unifyMovepushes the second copy’scomment_segmentsonto the first copy’s segments (G3). Result: the merged tree carries duplicate segments with identical(annotator, text).- Variations are added as parallel siblings even when identical, because
mergeMovesdoesn’t recurse into matching first-moves of variations (it grafts them whole — games.js:286-293). _nagsBySourceoverwrites by annotator key, so NAGs are idempotent.annotationsarrays union withSetdedup, so drawables are idempotent.
Consequence. Duplicate delivery (normal in distributed systems) would produce duplicated comment text and duplicated variation siblings. The merge is not safely re-runnable.
Fix sketch. The general fix is op IDs: every segment carries a
stable (annotator, sequenceNumber) or (annotator, opId); idempotent
merge becomes “if opId is already present, skip.” Variation grafting
similarly needs op IDs on the variation root. This is the load-bearing
piece — without op IDs, idempotence cannot be achieved structurally.
G10. Associativity
Section titled “G10. Associativity”Associativity ((A∪B)∪C = A∪(B∪C)) follows from commutativity for a
true merge semilattice. Since the gaps above break commutativity, the
formal property doesn’t hold today. But once G1–G9 are addressed, the
merge function would naturally be a CRDT-style state-based merge, and
associativity would follow.
The one place to spot-check after fixes: finalizeNags runs once at
end-of-merge under the current batch model. In an op-based decomposition
NAG resolution has to be performed incrementally (or lazily on read).
That’s a decomposition concern (§next), not an associativity concern.
Batch-only patterns that don’t decompose cleanly
Section titled “Batch-only patterns that don’t decompose cleanly”These are not bugs in the batch model — they are valid batch optimizations. But they don’t generalize to streaming.
B1. selectPrimary requires whole-input visibility
Section titled “B1. selectPrimary requires whole-input visibility”There is no “primary” in streaming. Sources arrive at their own time. Fix: eliminate the concept (§G2).
B2. finalizeNags requires whole-input visibility
Section titled “B2. finalizeNags requires whole-input visibility”The conflict detection (all annotators agreed) requires knowing every
contributor before deciding union-vs-migrate. In streaming, the first
two annotators may agree and the third disagree later, triggering a
retroactive migration.
Decomposition strategy. Two options:
- Lazy resolution at read time. Keep
_nagsBySourcealways. Renderer computes “agreed bare NAG or migrated per-author NAG” on demand. The data layer never makes the choice. - Incremental migration. Every time a new author contributes a NAG, recompute the agreement check and migrate if it now disagrees. Migrations are reversible: if a third agreeing author arrives later, un-migrate. This is harder to make idempotent.
Probably (a). Move the policy decision from write-time to read-time.
B3. mergeHeaders runs once with all sources known
Section titled “B3. mergeHeaders runs once with all sources known”Header merging is currently a one-shot batch step. In streaming, every new source could change the chosen header value. Probably not in OpenFile’s op set — headers are app-level metadata. Apps can layer their own resolution policy on top, or treat one author’s headers as canonical.
B4. The privileged-primary role
Section titled “B4. The privileged-primary role”Already covered in §G2. The streaming model has no notion of “the input that arrived first is privileged for tree-shape decisions.”
Decomposition sketch — what the streaming op set looks like
Section titled “Decomposition sketch — what the streaming op set looks like”If we squint at mergeGames, the discrete operations it implicitly
performs are:
addNode(parentId, san, opId, author) — creates a new tree node; idempotent via opId
addSegment(nodeId, text, opId, author) — adds a comment segment; idempotent via opId
setDrawable(nodeId, key, value, opId, author) — adds an arrow/square annotation; union-with-dedup
setScalar(nodeId, key, value, lamport, author) — sets eval/clk; resolution by domain rule (latest depth, etc.)
addNagContribution(nodeId, nags[], opId, author) — records this author's NAG set for the node
setVariationOwner(variationRootId, author, opId) — stamps a variation root with its owner
removeOwnContribution(opId, author) — tombstone; only valid for the author's own contributionsPlus derived/policy ops (NOT in the wire protocol — computed at render time or as separate concerns):
resolveNags(nodeId) — read-time NAG migration logicdisplayMainline(treeId) — render-time choice of mainline pathmergeHeaders(records) — app-level concern, not OpenFile'sProperties this op set would need:
- Op IDs are globally unique.
(authorHandle, sequenceNumber)is enough. Idempotence comes from dedup against the applied-set. - Anchor IDs are stable. A node ID must outlive tree restructuring (variation promotion, etc.) so causal dependencies don’t break.
- Causal delivery within an author’s stream. Sequence numbers are monotonic per author; an author’s op N+1 may depend on N existing. Cross-author dependencies handled by buffer-until-anchor-arrives.
- Cross-author ops commute on different objects. Already mostly true in Tabia’s design (different annotators own different segments and sub-trees); the fixes above tighten it to fully true.
Tabia’s existing session-edit API (playMove, setComment,
toggleNag, setShapeAnnotations, deleteFromHere,
promoteVariation in
game.js) would need an attribution-aware
overlay. Today these methods write to node.comment (single string),
node.nags (single array) — the un-attributed shape. OpenFile would
intercept these mutations and emit op-shaped events with the current
user’s author handle.
Recommendation
Section titled “Recommendation”Refactor Tabia into op-based primitives rather than building OpenFile as a parallel implementation. Three reasons:
-
The session API needs attribution anyway. Single-user editing that produces
node.comment(un-attributed) is already drifting from the merge representation (comment_segmentswith annotator). A unified op-based path means every mutation is attributed by construction, and the legacycommentfield is a derived read-time view. -
Batch merge becomes a sequence of streaming ops. Once the op set exists,
mergeGamesis implementable as “play each source’s ops into the tree in any order.” The batch path is preserved (just re-implemented over the op layer), the streaming path falls out for free, and there’s one merge implementation rather than two diverging ones. -
The fixes for G1–G9 are themselves a step toward op-based. The work to make the merge commutative is the work to remove batch-order dependencies, which is the work to make each merge step a discrete op.
A parallel-implementation approach (OpenFile reimplements the merge without touching Tabia) would fork the merge logic into two codebases and guarantee they diverge. Avoidable.
Suggested sequence
Section titled “Suggested sequence”- Op-set spec. Lock the discrete-op vocabulary (the sketch above), including op-ID shape, anchor semantics, and which operations are derived vs primary.
- Tabia refactor: op-based mutation core. Reshape
createGame’s internals so every mutation (playMove, setComment, toggleNag, etc.) emits and applies an op. Single-user mode = “I emit ops, I apply them locally, that’s the whole loop.” Attribution stamped from a single-user default (“you”). - Tabia refactor: op-based merge. Re-implement
mergeGamesas “parse each PGN into an op stream, apply all streams to a fresh tree.” Validate ACI properties hold by construction (test: randomized order produces identical trees). - OpenFile MVP: in-process broadcast. Add the wire layer that ships ops between two Tabia instances in the same process. No network yet. Validates the op set is sufficient.
- OpenFile MVP: WebSocket transport. Move the broadcast over a real socket. Identity injection from app shell. Causal-delivery buffering.
- Open problems we’ll know more about by then: variation promotion under concurrent edits (§G2 option choice), permissions/policy layer, presence indicators.
Steps 1–3 are pure Tabia work and unblock everything downstream. They also have intrinsic value for Tabia (cleaner mutation core, attribution-by-construction in single-user mode) independent of OpenFile.