Skip to content

Op vocabulary (spec)

Status: draft v0.6 (2026-05-17; capability-gated ops + delegate type) Prerequisite reading: Tabia target interface v0.2, Tabia lifecycle, Identity and trust v0.2, Audit Subsequent work: OpenFile implementation (capability-gate refactor).

Tracks the identity-spec v0.2 refactor (delegation chains + capability lattice replacing the flat trust-store model). Three concrete deltas:

  • New op type: delegate. Confers a capability bundle from one pubkey to another. Replaces v0.5’s addTrustedKey / removeTrustedKey (which lived in the identity spec). Full envelope definition is in identity v0.2 §3.2; this spec references it from §3.0.
  • Per-op capability requirements. Each chess-data op type now has a documented required cmd (/play, /comment, /moderate). The apply pipeline (§4.4) checks the signer’s derived capability set against this requirement before chess-data validation. See identity v0.2 §6 for the full mapping table.
  • Dual opId forms. Chess-data ops use (author, seq) as before. delegate ops use (author, hash) — content-addressed via SHA-256 of the canonical-JSON-without-signature payload. This avoids the per-author seq collision that would otherwise occur when multiple tabs share a signing key (e.g., the Powerline link pattern, identity v0.2 §7).

What’s unchanged from v0.5: the eight chess-data op types, ACI guarantees, HLC ordering, causal-delivery rules, visibility cascade, GC model, content-addressed node identity, mergeGames model, the persistence formats (§10).

A small follow-up to v0.4 that adds the persistence section (§10) — op log archive format, snapshot format, export/import APIs on the OpenFileTarget, and the architectural seam (“library defines format

  • API; storage location is app territory”) that mirrors Tabia’s PGN-string approach. Filled a real gap surfaced during transport-spec planning: apps had no defined way to save / restore OpenFile sessions.

The persistence format reuses the canonical JSON encoding already mandated by the identity spec for signing, so wire format ≡ archive format ≡ snapshot encoding. One canonicalization, three contexts.

The §11 “what’s NOT in spec” list is unchanged; §12 open questions unchanged; §13 (what’s next) reordered so persistence is part of the implementation sequence.

v0.3 was authored before Tabia’s lifecycle migration + Target extraction landed. v0.4 updated the upward-facing surface to match what shipped:

  • Node ids exposed as integers, not hash strings. Target spec v0.2 commits to integers. OpenFileTarget keeps content-addressed hash identity internally and exposes a stable hash↔int mapping. Motif and Tabia consumers see ints, same as with LocalTarget. (See §1.4.)
  • No NodeView parallel type. Tabia’s existing Node shape is what Targets expose. Multi-author detail moves to OpenFileTarget extension methods (§9.6).
  • No 'change' / 'positionChange' events. Replaced by onTargetChange({ slices: ('tree' | 'header')[] }). Game’s slice machinery (cursor / tree / header / view / engine) lives above Target and translates remote-op signals into slice fires.
  • Cursor lives on Game, not Target. Cursor is navigation/view state, not chess data — it doesn’t sync across collaborators by default. Per-user cursor relay (coach/student) and presence (shared spotlight) are sync-mode features, not Target methods. (See §9.6.)
  • Method names align with Target v0.2. addMoveaddNode, markDeleteddeleteSubtree, setDrawablessetShapeAnnotations, setMainChildpromoteToMainChild. Per- author reads (getMyComment etc.) move off the base contract.
  • Visibility ⇒ null in getNodes(). Tombstoned / cascade-hidden nodes appear as null, matching LocalTarget’s hard-delete shape. Consumers iterate uniformly; CRDT mechanics don’t leak. (See §5.4.)
  • Sync modes (spectator, follow-leader, collaborative) are a real part of the spec now, addressing the chess-app patterns that motivated the Target refactor. (See §9.6.)

What stays from v0.3:

  • The op vocabulary (addNode, addSegment, setDrawable, setScalar, addNagContribution, setMainChild, setOpDeleted, setNodeDeleted) — unchanged.
  • ACI guarantees, idempotence, causal-delivery rules, visibility rules, GC model — unchanged.
  • Content-addressed node identity, per-author seq — unchanged.
  • Logical-clock ordering for ops — model preserved, but the timestamp is now an HLC instead of plain Lamport (§1.3). Same ordering / tie-break semantics; added wall-clock approximation for UI affordances. This is a wire-format change.
  • Layer split: op layer (this spec) + transport layer (future spec).
  • mergeGames split: Tabia keeps batch-merge for 1:1; OpenFile has its own ACI-correct merge for collaborative bootstrapping.

OpenFile is a many:1 chess game library — multiple users coordinating on one shared game. The op vocabulary defines the discrete operations that OpenFile’s CRDT-based game record supports.

Ops are a runtime mechanism, not an output format. The artifact users produce is a PGN. The chess content authored in any OpenFile session — moves, comments, NAGs, drawables, per-author attribution — fits inside standard PGN (with [%ann] / [%cal] / [%csl] markers). Ops exist while a session is live so peers converge on one PGN with attribution preserved; they’re not the document. See README for the broader framing.

Three properties this spec guarantees:

  • Commutativity. Ops on different objects apply in any order with the same result. Ops on the same object commute unless contending for the same per-target attribute, in which case a deterministic tie-break selects one.
  • Idempotence. Any op delivered twice is a no-op the second time.
  • Causal validity. Ops referencing not-yet-existing anchors are buffered, not dropped or speculatively applied.

If those three hold per op, the merge function (applying a multiset of ops) is associative for free.

OpenFile’s job, structurally:

  1. Op layer (this spec) — defines how a CRDT-based chess game record is built, mutated, and rendered as a 1:1 view per user.
  2. Transport layer (future spec) — synchronizes the CRDT game record across user instances via WebSocket, watermark coordination, snapshot distribution.

The op layer adapts the CRDT record to Tabia’s target interface, so Tabia’s chess logic works against an OpenFile-backed game the same way it works against a LocalTarget-backed one.


These are decided. Locking here so the rest of the spec can lean on them.

1.1 Identity = stable opaque author handle

Section titled “1.1 Identity = stable opaque author handle”

Each author is identified by a string ("alice", "https://lichess.org/@/avruk", "Stockfish 17"). Identity is opaque to the data layer; resolution (URL → handle, comma-swap, alias mapping) happens at read time. Authority over an op belongs to the author handle that stamped it; the op layer does not verify identity. App-level identity layers (crypto-signed handles, federated identity) layer on top.

Handle changes are forward-only. A user switching from “WestPortalian” to “John Boyer” just changes the handle in their client config; new ops use the new handle, old ops keep the old. Per-author seq for the new handle starts fresh at 1. Display unification (rendering both as the same person) is app-layer. True cryptographic identity unification is a v2+ concern.

Chess-data ops use opId = { author, seq } where seq is a monotonic per-author counter starting at 1. Per-author ops are emitted in seq order; the receiving side enforces causal validity per author by buffering ops whose predecessor seq hasn’t yet been applied.

Trust ops (delegate, future revoke) use opId = { author, hash } where hash is the content hash of the op’s canonical-JSON-without- signature payload. Trust ops are content-addressed because the same issuer pubkey may sign multiple structurally-distinct delegations concurrently (e.g., the Powerline link pattern from identity v0.2 §7 where many tabs sign per-tab delegations as the same link audience). The sequential seq model would force coordination that the shared-signer use cases can’t provide.

Both forms are idempotence keys. Every replica maintains an applied-set of opIds; receiving an op already in the applied-set is a no-op.

1.3 Hybrid Logical Clock (HLC) for ordering and tie-breaks

Section titled “1.3 Hybrid Logical Clock (HLC) for ordering and tie-breaks”

Every op carries an HLC value hlc: HLC. HLCs provide a deterministic total order across all ops (used for LWW tie-breaks) AND approximate wall-clock time (used for UI affordances like “Alice added this 3 minutes ago”). Same correctness properties as a pure Lamport clock, plus wall-clock approximation.

Encoding. HLC is a 64-bit value:

  • High 48 bits: physical wall-clock millis (UTC; Date.now())
  • Low 16 bits: logical counter

The counter increments when multiple events occur within the same physical millisecond. 16 bits = 65k events/ms, which is plenty.

Apply rules. Each replica maintains its current HLC. On emit:

pc = Date.now()
if pc > hlc.physical:
hlc = { physical: pc, logical: 0 }
else:
hlc.logical += 1
op.hlc = hlc

On receive of op O:

pc = Date.now()
maxP = max(hlc.physical, O.hlc.physical, pc)
if maxP == hlc.physical && maxP == O.hlc.physical:
newL = max(hlc.logical, O.hlc.logical) + 1
else if maxP == hlc.physical:
newL = hlc.logical + 1
else if maxP == O.hlc.physical:
newL = O.hlc.logical + 1
else:
newL = 0
hlc = { physical: maxP, logical: newL }

Tie-breaks. Higher HLC wins. Ties broken by author (lexicographic).

Properties.

  • HLC respects causality: if A causally precedes B, hlc(A) < hlc(B).
  • HLC approximates wall-clock time: hlc.physical is a sane Date.
  • Concurrent ops can have any HLC relationship — same as Lamport. For merge correctness this doesn’t matter; we only need deterministic resolution of same-target contests.

Clock skew handling. Peers’ wall clocks can disagree. The HLC algorithm handles this by tracking the maximum observed physical time; a peer whose clock is behind will pull forward to match the network’s HLC frontier on receive. The reverse — a peer whose clock runs ahead — is bounded: replicas reject (or buffer) ops with HLC more than MAX_SKEW_MS ahead of local physical time. Recommended default: 60_000 (60 seconds).

Clock-jumps-backward (NTP corrections, manual time changes): the logical counter increments past the physical clock when needed. HLC never goes backward across emits.

Persistence. Replicas persist their current HLC across sessions. On startup: hlc = max(persistedHlc, currentPc) (with the physical- advances-counter-resets rule).

Display. UI code that wants “X minutes ago” or “yesterday” reads hlc.physical and formats it as a Date. Order-by-HLC matches order- by-actual-time when wall clocks are well-synchronized.

1.4 Node identity = content-addressed hash (internal) + stable int (external)

Section titled “1.4 Node identity = content-addressed hash (internal) + stable int (external)”

Each tree node is uniquely identified internally by a content hash:

nodeHash(node) = hash(parent.hash, san)
rootHash = hash(startFen)

The hash is a pure function of the node’s content. Two addNode ops emitted by different authors for the same (parent_hash, san) produce the same nodeHash. Both ops are recorded; the node has a set of affirmations, not a single creator.

Externally — to Tabia and through it to Motif components — node ids are integers, matching the Target contract v0.2. OpenFileTarget maintains a stable nodeHash → int mapping, assigned in the order this target first sees each node. The int stays the same for the lifetime of the target instance: deletion + restoration of a node preserves its int; remote ops that introduce a node assign it the next available int at apply time.

The mapping is per-target (i.e., per-user). Different replicas may assign different ints to the same nodeHash. Ints are never shared across the wire — only hashes are. Consumers (Motif, app code) never see hashes; they only see their local replica’s ints.

This is a concession from OpenFileTarget’s side that buys big simplification for Motif components (which assume integer ids).

Every op references zero or more existing entities by stable identifiers:

  • Node references = nodeHash. Used by addNode (parent), addSegment, setDrawable, setScalar, addNagContribution, setMainChild, setNodeDeleted.
  • Op references = opId. Used only by setOpDeleted (to target a specific op for tombstoning).

The receiving side enforces causal validity by buffering ops whose anchors don’t yet exist locally. Buffered ops drain whenever a new op enters the applied-set that resolves a missing anchor.

1.6 No privileged mainline at the data layer

Section titled “1.6 No privileged mainline at the data layer”

When two authors’ lines diverge from a shared parent, they are both children of that parent. There is no notion of “primary’s line stays mainline; other gets demoted to variation.” Sibling children of a parent are ordered deterministically (see §4.1).

Display mainline (which child renders inline vs. in parens) is controlled by setMainChild (§3.6), an LWW register per parent.

1.7 variationOwner is derived from affirmers

Section titled “1.7 variationOwner is derived from affirmers”

A variation’s “owner” for display purposes is whichever author affirmed its root node first (by HLC order, author tiebreak). It’s not a stored attribute; it’s computed from the set of addNode affirmations.

Any author can issue any tombstone op against any target. The op layer does not enforce “you can only delete your own work.” Permissions are an app-layer concern.

Concretely: setOpDeleted and setNodeDeleted are not author-scoped. They are LWW registers settable by anyone. Concurrent or sequential ops from different authors resolve by HLC tie-break.

App-layer enforcement can wrap the emit side: refuse to emit a tombstone if the user lacks permission; surface a “are you sure?” prompt for cross-author destruction; audit cross-author deletions via the op log.


OpenFile is the data layer for many:1 chess collaboration. It has a narrow contract upward (to Tabia, via the target interface) and a narrow contract downward (to transport, future spec). Within OpenFile, two layers:

Owns:

  • Op vocabulary — the set of legal op shapes and payload schemas.
  • Op validation — structural correctness, anchor existence, per-author seq monotonicity, schema conformance.
  • Apply pipeline — fold an op into state, update derived structures, emit transition events.
  • Causal delivery buffering — hold ops whose anchors haven’t arrived yet; drain when they do.
  • Idempotence machinery — applied-set tracking, dedup on receive.
  • CRDT-based game record — multi-author state with content-addressed nodes, per-author maps, deletion registers.
  • Visibility computation — ancestor-cascade rules.
  • Conflict resolution rules — LWW for registers, set semantics for multisets.
  • Local shadow GC — autonomous removal of shadowed register-update ops.
  • Watermark enforcement — reject below-watermark ops.
  • Snapshot generation/application — serialize/hydrate state.
  • Event emission — fire structured events on state transitions.
  • The OpenFile target class — wraps the op layer to comply with Tabia’s target interface (§9).
  • OpenFile’s mergeGames — multi-PGN batch merge on the op layer; ACI-correct.

Owns:

  • Wire protocol — JSON/binary, schemas, versioning.
  • Connection management — WebSocket, reconnection, heartbeats.
  • Op broadcast / routing — ship local ops to peers; deliver remote ops to op layer.
  • Membership tracking — who’s connected, who’s “active enough” to count for stability.
  • Causal stability detection — per-replica high-water-mark gossip, detect when ops have fully propagated.
  • Watermark advancement — call into op layer when stability is confirmed.
  • Snapshot distribution — ship snapshots to joining or recovering replicas.

2.3 What’s delegated upward (to the app)

Section titled “2.3 What’s delegated upward (to the app)”
ConcernOwned by
Identity (who is “alice”?)App
Authorization (can alice emit?)App
Persistence (where is the op log stored?)App/storage layer
Notification UXApp
”Permanent delete” intentApp

2.4 The TabiaTarget contract is the upward surface

Section titled “2.4 The TabiaTarget contract is the upward surface”

To Tabia, OpenFile is “just another target” — conformant to the v0.2 Target contract. The OpenFile target class implements:

  • Reads: translate CRDT state into Tabia’s Node shape, with aggregated values (joined comments, resolved NAGs, etc.) on the base view and per-author detail on extension methods (§9.6).
  • Writes: translate Tabia mutator calls (addNode, deleteSubtree, promoteToMainChild, setComment, setNagSet, setShapeAnnotations) into op emissions + apply.
  • Upward signal: onTargetChange({ slices: ('tree' | 'header')[] }) fires when remote ops apply. Game’s slice machinery translates this into slice fires for consumers.

Game owns the cursor, view prefs, engine slice, and the entire slice reactive system. Target’s job is purely chess-data storage — matching the v0.2 ownership model. Tabia’s mutation primitives (playMove, setComment, etc.) work against the OpenFile target identically to LocalTarget; the chess logic doesn’t change.


Eight chess-data ops (set-additions and LWW register-updates) plus one trust op (delegate, defined in identity v0.2 §3.2 and summarized in §3.0 below).

All ops share the base envelope:

type OpEnvelope = {
opId: OpId; // see below — two forms
hlc: HLC; // hybrid logical clock (see §1.3)
signature: string; // base64 Ed25519 signature (identity spec v0.2)
attribution?: string; // optional human-readable author label (§1.5)
};
// Chess-data ops: sequential per-author counter
type SeqOpId = { author: string; seq: number };
// Trust ops (delegate, revoke): content-addressed
type HashOpId = { author: string; hash: string };
type OpId = SeqOpId | HashOpId;

The signature and chain-walking verification are defined in identity v0.2 §3-4. The capability gate (which signer can emit which op type) is defined in identity v0.2 §6 and summarized per-op-type below.

1.5 Attribution (human-readable author label)

Section titled “1.5 Attribution (human-readable author label)”

opId.author is the cryptographic identity (a pubkey). The optional envelope field attribution carries an orthogonal human-readable label that survives imports.

Two distinct identities apply to a contribution:

  • Cryptographic signer (opId.author) — authoritative, used for capability checks and signature verification.
  • Annotator (attribution) — informational, used for display. Not cryptographically authenticated; tamper-evident only by virtue of being inside the signed payload.

Use cases:

  • PGN import. mergeGames reads each source’s annotator (PGN headers / [%ann] markers) and sets attribution on every op it emits for that source. Imported content shows the original annotator’s name rather than the ephemeral signer’s truncated pubkey.
  • Labeled contributions in collab sessions. “From a Stockfish run at depth 30,” “Magnus said this in his lecture” — labels that aren’t a cryptographic claim about identity but are useful for readers.

Display precedence in renderers:

  1. If op.attribution present → use it.
  2. Else → resolve opId.author via the app’s display-name function.
  3. Else → fall back to a truncated pubkey.

Backward-compatible: ops without attribution behave exactly as before. Old logs / snapshots remain valid.

Trust ops. Two symmetric ops update the LWW permission registers. See identity v0.4 §3.2 for the full envelopes and apply semantics.

type Grant = OpEnvelope & {
type: 'grant';
iss: string; // issuer pubkey (== opId.author)
aud: string | "*"; // audience pubkey, or "*" for defaults
cmd: string[]; // capabilities being granted
};
type Revoke = OpEnvelope & {
type: 'revoke';
iss: string; // issuer pubkey (== opId.author)
target: string | "*"; // pubkey whose caps to remove, or "*"
cmd: string[]; // capabilities being revoked
};

opId is { author, seq } (sequential, per-author, monotonic) for both — same form as every other op type. No content-addressed opId form exists in v0.4.

Apply rules (identity v0.4 §4.2):

  • Both: verify Ed25519 sig; check cmd ⊆ effectiveCaps(iss).
  • grant: check /grant ∈ effectiveCaps(iss) (bootstrap exception when permsLww is empty and op self-grants /).
  • revoke: check /revoke ∈ effectiveCaps(iss) AND (iss == target OR strict-superset rule per identity §5.3).
  • Apply: set per-cmd LWW register for (aud, c) or (target, c) to GRANTED/REVOKED@op.hlc.

OpId form. Sequential { author, seq }.

Apply. Validated per identity v0.4 §4.2. Updates state.permsLww. Affects subsequent capability checks via effectiveCaps.

Anchor. iss (the issuer must have caps in permsLww, or the op is buffered on iss:<iss> until the issuer is granted). Bootstrap op (first op of session) is exempt.

Idempotence. Via (author, seq) like all other ops.

Required capability:

  • grant: /grant + cmd ⊆ issuer’s caps (or bootstrap path)
  • revoke: /revoke + cmd ⊆ issuer’s caps + strict-superset for non-self revokes

Set-addition. Affirms that a node exists as a child of an existing parent.

type AddNode = OpEnvelope & {
type: 'addNode';
parentNodeHash: NodeHash;
san: string;
};

Apply. Validate that parentNodeHash resolves (else buffer). Compute childHash = hash(parentNodeHash, san). Validate that san is legal at parent’s position via local chess rules (else reject — data integrity; see “Validate on receive” below). Add this addNode op to the affirmation set of childHash. If childHash was not previously alive, the node is created.

Anchor. parentNodeHash.

Idempotence. Via opId in applied-set.

Commutativity. Two addNodes targeting the same (parent, san) content-hash collapse to one node with multiple affirmers.

Required capability: /play.

Set-addition. Adds a comment segment to an existing node.

type AddSegment = OpEnvelope & {
type: 'addSegment';
nodeHash: NodeHash;
text: string;
};

Apply. Validate nodeHash resolves (else buffer). Append the segment to the node’s segment-set, keyed by opId. The segment’s author is opId.author.

Anchor. nodeHash.

Idempotence. Via opId.

To edit a segment: tombstone the old segment (§3.7) and emit a new addSegment. No editSegment op.

Required capability: /comment.

Set-addition. Adds an arrow or square highlight.

type SetDrawable = OpEnvelope & {
type: 'setDrawable';
nodeHash: NodeHash;
kind: 'arrow' | 'square';
value: string;
};

Apply. Validate nodeHash. Add (kind, value, opId.author) to the node’s drawable-set with set semantics.

Required capability: /comment.

LWW register-update. Sets a scalar annotation on a node, per-author, per-key.

type SetScalar = OpEnvelope & {
type: 'setScalar';
nodeHash: NodeHash;
key: string; // 'eval', 'clk', 'depth'
value: number | string;
};

Apply. Validate nodeHash. Store as node.scalars[opId.author][key] = { value, hlc, opId }. Each author has their own per-key slot. Same-author later ops with the same key shadow earlier by HLC.

Required capability: /comment.

LWW register-update. Records this author’s NAG set for a node.

type AddNagContribution = OpEnvelope & {
type: 'addNagContribution';
nodeHash: NodeHash;
nags: number[];
};

Apply. Validate nodeHash. Store as node.nags[opId.author] = { nags, hlc, opId }. Per-author LWW.

Render-time resolution walks all authors’ current NAG sets and decides “agreed bare NAG” vs. “per-author migrated NAGs.”

Required capability: /comment.

LWW register-update. Sets which child of a parent is the display mainline.

type SetMainChild = OpEnvelope & {
type: 'setMainChild';
parentNodeHash: NodeHash;
childNodeHash: NodeHash;
};

Apply. Validate both anchors. Validate that childNodeHash is currently a child of parentNodeHash (else reject). Update parent.mainChild = { childNodeHash, hlc, opId } per LWW.

Default mainChild. When no setMainChild op has won for a parent, the default is the deterministic-first-child by sibling-ordering (§4.1).

Required capability: /play.

LWW register-update. Marks a specific op as deleted (tombstoned).

type SetOpDeleted = OpEnvelope & {
type: 'setOpDeleted';
targetOpId: OpId;
deleted: boolean;
};

Apply. Validate targetOpId exists in applied-set (else buffer). Update the per-op deletion register per LWW.

Cross-author allowed. The CRDT allows any author to target any op. The capability gate enforces permission policy:

Required capability:

  • /play if targetOpId.author == op.opId.author (deleting your own op)
  • /moderate otherwise (deleting another author’s op)

Anchor. targetOpId. This is the one op type with an opId-based anchor — significant for GC.

LWW register-update. Marks a node as deleted at the node level.

type SetNodeDeleted = OpEnvelope & {
type: 'setNodeDeleted';
nodeHash: NodeHash;
deleted: boolean;
};

Apply. Validate nodeHash. Update the per-node deletion register per LWW.

When to use which. setOpDeleted targets specific contributions (e.g., one comment segment). setNodeDeleted targets a node as a whole. “Delete subtree” UX typically uses setNodeDeleted cascading on emit.

Required capability:

  • /play if every node in the subtree being deleted is authored by op.opId.author (deleting your own subtree)
  • /moderate if any node in the subtree has a different author

Children of a parent are ordered for rendering by:

  1. mainChild renders first if a setMainChild op has resolved.
  2. Remaining children by their addNode op’s (hlc, author), ascending. For nodes with multiple affirmations, the lowest-hlc affirmation’s tuple is the sort key.

Segments within a node are ordered by (hlc, author), ascending.

The receiving side maintains:

  • applied-set: Set<opId> of every op already applied.
  • buffer: ops received but not yet applied, indexed by missing anchor.

On receiving op O:

  1. If O.hlc < watermark → reject with “fetch snapshot” response.
  2. If O.opId ∈ applied-set → drop (idempotent). (For chess-data ops this is dedup by (author, seq); for delegate ops, by (author, hash) — see §3.)
  3. Signature + chain verification (identity v0.2 §4.2). Reject on failure.
  4. Per-author causality (chess-data ops only): if O.opId.seq > 1, ensure (O.opId.author, O.opId.seq - 1) ∈ applied-set. Else buffer. delegate ops are content-addressed and have no per-author seq ordering — they’re causally constrained only by their proof anchor.
  5. Anchor existence: for each anchor A referenced by O, ensure A resolves locally. Else buffer. For delegate ops, proof (the parent delegation’s hash, if non-root) is the anchor.
  6. Capability check (chess-data ops only): derive signer’s capability set at O.hlc per identity v0.2 §4.3. Verify the op-type’s required cmd (§3) is in that set. Reject on failure; capability failures may also buffer if the relevant delegation hasn’t yet arrived (identity v0.2 §4.4).
  7. Op-type validation (see §4.4 below). Reject on failure — don’t apply, don’t add to applied-set, don’t buffer.
  8. If all checks pass: advance clock, apply O, add opId to applied-set. Drain buffer for any newly-resolved anchor.

The op layer does not trust the emitter. Every receiver — including the emitting replica itself — re-validates each op’s data-integrity contract against its own local state before applying. A malicious or buggy peer running a tampered legality check can’t sneak invalid ops into honest peers’ replicas; the receive-side check rejects them.

Per-op-type validation rules:

OpValidation
addNodesan is legal at parent.fen via standard chess rules (chess.js or equivalent). Reject if illegal.
addSegmentnodeHash resolves to an alive node. text is well-formed (no embedded null bytes; reasonable length cap, transport-layer concern).
setDrawablenodeHash resolves. value matches the kind’s syntax (/^[GRBY][a-h][1-8]([a-h][1-8])?$/ for arrows, etc.).
setScalarnodeHash resolves. key/value are within whatever schema the app declares (op layer doesn’t enforce a key registry).
addNagContributionnodeHash resolves. nags are valid integer NAG codes.
setMainChildchildNodeHash is currently a child of parentNodeHash (else reject — invariant violation).
setOpDeletedtargetOpId exists in applied-set.
setNodeDeletednodeHash resolves.

The local-emit path also runs these checks. An honest emitter never generates an invalid op; the local check is a safety net catching caller bugs (Tabia validates SAN before calling addNode, but if a non-Tabia caller skips that step, the receive-side check still fires).

Rejection notification to the emitting peer is a transport-spec concern. The op layer just rejects silently; the transport layer may choose to emit a “your op N was rejected” response for UX purposes.


The op log is the source of truth. The rendered state is derived. Visibility is what users see; data is what’s stored.

A node is alive if it has at least one alive addNode affirmation.

A node is actually visible if:

  1. It is alive, AND
  2. Its per-node deletedState (setNodeDeleted) is unset or false, AND
  3. Its parent is actually visible (recursive). Root is always visible.

A segment / drawable / scalar / NAG contribution is visible if:

  1. Its own per-op deletion register is unset or false, AND
  2. Its anchor node is actually visible.

Visibility is a state predicate. Notifications fire on transitions of state predicates, not on specific op-application events.

Events:

  • op-applied — any op enters applied-set.
  • node-became-alive — zero alive affirmations → ≥1.
  • node-became-hidden — actually-visible flipped to false.
  • node-became-visible — actually-visible flipped to true.
  • contribution-became-hidden — anchored op transitioned to hidden.
  • contribution-became-visible — inverse.

Each event carries the affected op(s), the cause (which op triggered the transition), and context (blocking ancestor, etc.).

Predicates are evaluated against current state, so transitions fire regardless of arrival order. Whichever op flipped the predicate triggers the event.

5.4 External exposure: invisible ⇒ null in getNodes()

Section titled “5.4 External exposure: invisible ⇒ null in getNodes()”

The Target contract’s getNodes() returns (Node | null)[] — nulls in deleted slots, live nodes otherwise. OpenFileTarget enforces this by exposing only actually-visible nodes through getNodes() and getNode(id). Tombstoned nodes, cascade-hidden nodes (parent deleted), and not-yet-affirmed nodes (zero alive affirmations) appear as null at their assigned int slot.

This matches LocalTarget’s hard-delete shape and lets Tabia consumers iterate uniformly. The CRDT mechanics — affirmation sets, deletion registers, cascading visibility — don’t leak through the Target interface. Apps that want to render shadow / hidden nodes as a UX choice use OpenFileTarget-only extension methods (§9.6).

A node toggling between visible and invisible (e.g., via cross-author delete + restore, §3.7) keeps its assigned int slot. The slot contents oscillate between the node object and null. Consumers that cached the int never see id reassignment.


The op log grows monotonically. GC removes ops whose effect is preserved in the remaining state. Two tiers:

The op layer may at any time GC a shadowed LWW register-update op if:

  • The op is an LWW register-update (setScalar, addNagContribution, setMainChild, setOpDeleted, setNodeDeleted).
  • A higher-HLC op targeting the same register exists.
  • The shadowed op has no inbound anchors (no setOpDeleted references its opId).

Why safe. Shadowed ops contribute nothing to the rendered state. HLC monotonicity prevents resurrection.

The transport layer tracks causal stability across replicas and calls advanceWatermark(L) on the op layer. After this:

  • Reject any incoming op with hlc < L (“fetch snapshot”).
  • Strengthened idempotence: below-watermark opIds can be removed from applied-set.
  • LWW winners with hlc ≤ L become GC-eligible.
  • (Target op, tombstone) pairs both below watermark can be removed.

Snapshots serialize current state + watermark + applied-set summary. New replicas hydrate from a snapshot rather than replaying the full log.

{
version: 'openfile-snapshot-v1',
generatedAt: hlc,
watermark: hlc,
state: { nodes, edges, segments, drawables, scalars, ... },
appliedSetSummary: { perAuthorMaxSeq: { alice: 47, bob: 92 } },
}

The transport layer orchestrates snapshot timing and distribution.


The audit’s ACI gap inventory was a description of Tabia’s existing batch mergeGames. v0.3 doesn’t fix those gaps in Tabia — Tabia’s mergeGames serves a 1:1 use case where ACI-correctness isn’t required. ACI matters for OpenFile, where the same logical operations occur in a many:1 context.

For OpenFile’s mergeGames-equivalent (used to bootstrap a collaborative session from multiple PGN sources):

  • G1, G3, G4, G6, G7, G8 (variation/segment/header ordering by input order): resolved by content-addressed identity + deterministic ordering (§1.4, §4.1).
  • G2 (privileged mainline): resolved by setMainChild register + default-by-sibling-order (§1.6, §3.6).
  • G5 (legacy comment field as concatenation): resolved — comment is derived from segments at read time.
  • G9 (idempotence on duplicate input): resolved by opId-keyed applied-set (§1.2).
  • B1-B4 (batch-only patterns): resolved — OpenFile’s mergeGames parses each PGN into an op stream and applies; no whole-input visibility required.

8.1 Bob comments on Alice’s variation (causal delivery)

Section titled “8.1 Bob comments on Alice’s variation (causal delivery)”
  1. Alice emits addNode(parent=N0_hash, san='Nf3', opId={alice,6}, hlc=11). Nf3_hash = hash(N0_hash, ‘Nf3’).
  2. Op reaches Bob. His clock advances.
  3. Bob emits addSegment(nodeHash=Nf3_hash, text='nice move', opId={bob,4}, hlc=13).
  4. Both reach Charlie, but Bob’s arrives first. Charlie buffers Bob’s op indexed by Nf3_hash.
  5. Alice’s op arrives at Charlie. Apply; register Nf3_hash. Drain buffer: Bob’s segment applies.

Order-independent final state.

  1. Alice: addNode(parent=N0, san='Nf3', opId={alice,6}, hlc=11).
  2. Bob: addNode(parent=N0, san='Nf3', opId={bob,3}, hlc=12).

Same content-hash. One node, two affirmations. Variation owner (for display) is Alice (lower hlc).

  1. Alice: setMainChild(parent=N0, child=Nf3_hash, opId={alice,8}, hlc=15).
  2. Bob: setMainChild(parent=N0, child=Bb5_hash, opId={bob,5}, hlc=14).

Alice’s hlc=15 wins LWW. Bob’s op shadowed, locally GC-eligible.

  1. Alice: addSegment(opId={alice,15}, hlc=30, ...).
  2. Bob: setOpDeleted(target={alice,15}, deleted=true, opId={bob,8}, hlc=35).
  3. Alice: setOpDeleted(target={alice,15}, deleted=false, opId={alice,18}, hlc=40).

Alice’s hlc=40 wins; segment visible again. Bob’s op is shadowed, locally GC-eligible. Original addSegment alive throughout.

8.5 Alice’s offline edit lands in deleted region

Section titled “8.5 Alice’s offline edit lands in deleted region”
  1. Alice and Bob synced through mainline 1-80.
  2. Alice goes offline.
  3. Bob deletes nodes 60-80 via setNodeDeleted ops.
  4. Alice (offline) emits addNode branching from node 65 (variation).
  5. Alice rejoins. Ops flow.
  6. Apply Alice’s addNode: anchor node65_hash exists. Alice’s new node has 1 alive affirmation.
  7. Visibility check: parent (node65) has deletedState=true → hidden. Cascade: Alice’s new node is hidden.
  8. Pipeline fires contribution-became-hidden event for Alice’s op, blocker = Bob’s setNodeDeleted on node65.
  9. App layer composes UX: “While you were offline, Bob deleted moves 60-80. Your analysis from move 65 is preserved but hidden. [Restore / Move / Discard / Keep as-is]“
  1. Bob: setNodeDeleted(N, true, opId={bob,8}, hlc=35).
  2. Bob: setNodeDeleted(N, false, opId={bob,9}, hlc=40).
  3. Op layer detects: opId={bob,8} is shadowed. No inbound anchors.
  4. Local GC removes opId={bob,8}. Applied-set retains the opId for idempotence.
  1. Transport confirms all replicas have applied ops up to hlc=45.
  2. Transport calls advanceWatermark(45) on op layer.
  3. Both Bob’s setNodeDeleted ops (hlc 35, 40) are below watermark.
  4. The winner determines register value (N visible); both ops can be removed.
  5. Below-watermark opIds removed from applied-set. Stale ops rejected.

§9 How the OpenFile target implements TabiaTarget

Section titled “§9 How the OpenFile target implements TabiaTarget”

The OpenFile target wraps the op layer and exposes Tabia’s v0.2 target interface. Below: how each method maps. Cursor isn’t here — it lives on Game.

9.1 Reads — getNodes, getNode, getRecord, getStartingFen

Section titled “9.1 Reads — getNodes, getNode, getRecord, getStartingFen”
class OpenFileTarget implements TabiaTarget {
// Tabia consumers see ints; internally we keep hashes.
private nodesByInt: (NodeRecord | null)[] = []; // int → record
private intByHash: Map<NodeHash, number> = new Map();
private hashByInt: Map<number, NodeHash> = new Map();
getNodes(): (Node | null)[] {
// Live read-only array. Invisible (tombstoned / cascade-hidden /
// un-affirmed) nodes appear as null, per §5.4.
return this.nodesByInt.map((rec, id) =>
rec && this.isActuallyVisible(rec.hash) ? this.toNode(id, rec) : null,
);
// Implementations may cache this and update incrementally on each
// op apply; the contract just says it's the read interface.
}
getNode(id: number): Node | null {
const rec = this.nodesByInt[id];
if (!rec || !this.isActuallyVisible(rec.hash)) return null;
return this.toNode(id, rec);
}
getRecord(): GameRecord {
// PGN tag-pair record. May be a shallow merge of seed + any header
// contributions (header editing arrives in v2; for now this is the
// seed record set at construction).
return { ...this.record };
}
getStartingFen(): string {
return this.startingFen;
}
}
private toNode(id: number, rec: NodeRecord): Node {
const hash = rec.hash;
return {
id,
parentId: rec.parentId, // -1 for root
san: rec.san,
fen: this.computeFen(hash),
from: rec.from,
to: rec.to,
ply: rec.ply,
mainChild: this.resolvedMainChildInt(hash),
children: this.visibleChildrenIntsInOrder(hash),
// Aggregated annotations (across all authors):
comment: this.aggregatedComment(hash),
nags: this.resolvedNags(hash),
annotations: this.aggregatedShapeAnnotations(hash),
};
}

The base Node shape matches LocalTarget’s. Per-author detail (segments, nagsByAuthor, drawablesByAuthor) is not on this view — it’s exposed via §9.6 extension methods.

addNode(parentId: number, payload: NodePayload): number {
const parentHash = this.hashByInt.get(parentId);
if (parentHash === undefined) return -1;
const op = this.emit({
type: 'addNode',
parentNodeHash: parentHash,
san: payload.san,
// opId, hlc stamped by op layer
});
this.apply(op);
// The hash is deterministic from (parent, san); assign or reuse int.
const childHash = computeNodeHash(parentHash, payload.san);
return this.intForHash(childHash); // assigns next slot if new
}
deleteSubtree(nodeId: number): void {
const hash = this.hashByInt.get(nodeId);
if (hash === undefined) return;
// Cascade on emit: walk subtree (alive descendants), emit
// setNodeDeleted for each. The op layer's apply pipeline + visibility
// rules turn this into hidden nodes (which getNodes() shows as null).
for (const descendantHash of this.aliveSubtreeFrom(hash)) {
this.emit({
type: 'setNodeDeleted',
nodeHash: descendantHash,
deleted: true,
});
}
// Apply emits inline so the local view updates synchronously.
}
promoteToMainChild(nodeId: number): boolean {
const childHash = this.hashByInt.get(nodeId);
if (childHash === undefined) return false;
const rec = this.nodesByInt[nodeId];
if (!rec) return false;
const parentHash = this.hashByInt.get(rec.parentId);
if (!parentHash) return false;
// No change if already mainChild.
if (this.resolvedMainChildHash(parentHash) === childHash) return false;
this.apply(this.emit({
type: 'setMainChild',
parentNodeHash: parentHash,
childNodeHash: childHash,
}));
return true;
}
setComment(nodeId: number, text: string | null): void {
const hash = this.hashByInt.get(nodeId);
if (hash === undefined) return;
// Tombstone any existing alive segments from current user on this node.
const myExisting = this.aliveSegments(hash)
.filter((s) => s.author === this.currentAuthor);
for (const segment of myExisting) {
this.apply(this.emit({
type: 'setOpDeleted',
targetOpId: segment.opId,
deleted: true,
}));
}
// If new text provided, emit addSegment.
if (text) {
this.apply(this.emit({
type: 'addSegment',
nodeHash: hash,
text,
}));
}
}
setNagSet(nodeId: number, nags: number[] | null): void {
const hash = this.hashByInt.get(nodeId);
if (hash === undefined) return;
// Per-author LWW: emit addNagContribution replacing the user's
// contribution. The op layer resolves cross-author conflicts.
// null collapses to []: the user's contribution becomes empty, but
// OTHER authors' NAG contributions remain. (Aggregated view depends
// on §3.5 resolution rules.)
this.apply(this.emit({
type: 'addNagContribution',
nodeHash: hash,
nags: nags ?? [],
}));
}
setShapeAnnotations(nodeId: number, arrows: string[], squares: string[]): void {
const hash = this.hashByInt.get(nodeId);
if (hash === undefined) return;
// Tombstone user's existing drawables; emit new setDrawable per shape.
const myExisting = this.aliveDrawables(hash)
.filter((d) => d.author === this.currentAuthor);
for (const d of myExisting) {
this.apply(this.emit({
type: 'setOpDeleted',
targetOpId: d.opId,
deleted: true,
}));
}
for (const value of arrows ?? []) {
this.apply(this.emit({ type: 'setDrawable', nodeHash: hash, kind: 'arrow', value }));
}
for (const value of squares ?? []) {
this.apply(this.emit({ type: 'setDrawable', nodeHash: hash, kind: 'square', value }));
}
}

When Tabia adds setHeader / removeHeader to the Target contract, OpenFileTarget will implement them as per-author LWW register-updates on a headerByAuthor[author][key] structure, with aggregation rules analogous to NAG resolution. Defer until Tabia ships the surface.

OpenFileTarget implements onTargetChange (LocalTarget doesn’t). Fires when remote ops apply and change the visible tree or record:

onTargetChange(handler: (info: TargetChangeInfo) => void): () => void {
// Subscribe to the op-apply pipeline. After each remote-op batch
// applies, fire with the set of affected slices.
return this.bus.subscribe('remote-applied', (batch) => {
const slices: ('tree' | 'header')[] = [];
if (batch.touchedTreeNodes.size > 0) slices.push('tree');
if (batch.touchedHeaders.size > 0) slices.push('header');
if (slices.length > 0) handler({ slices });
});
}

Game subscribes once at construction and translates { slices } into its own slice fires — see Target spec §3. Local-mutation paths (Game calling addNode, etc.) don’t go through onTargetChange; Game already knows what changed because it called the mutator.

9.6 OpenFile-target-only extension methods

Section titled “9.6 OpenFile-target-only extension methods”

For OpenFile-aware consumers (transport layer, multi-author UI, sync-mode configuration). None of these are part of the base Target contract.

class OpenFileTarget implements TabiaTarget {
// ── Multi-author views (not on base Node shape) ──────────────────
getMultiAuthorView(nodeId: number): {
segments: Segment[];
nagsByAuthor: Record<Author, number[]>;
drawablesByAuthor: Record<Author, Drawables>;
scalarsByAuthor: Record<Author, Record<string, ScalarValue>>;
};
// "My contribution" reads — useful for "edit my comment" UX.
getMyComment(nodeId: number): string | null;
getMyNags(nodeId: number): number[];
getMyDrawables(nodeId: number): Drawables;
// ── Op-level access (for transport) ──────────────────────────────
getOpLog(): Op[];
receiveOp(op: Op): ReceiveResult; // from transport
onOpEmitted(cb: (op: Op) => void): () => void; // for transport
// ── CRDT machinery ───────────────────────────────────────────────
createSnapshot(): Snapshot;
applySnapshot(s: Snapshot): void;
advanceWatermark(L: number): void;
// ── Rich events (state-predicate transitions) ────────────────────
onContributionHidden(cb): () => void;
onContributionRestored(cb): () => void;
onNodeBecameAlive(cb): () => void;
onNodeBecameHidden(cb): () => void;
}

Tabia is unaware of these. They’re for OpenFile-aware code: transports (receiveOp / onOpEmitted), apps that render multi-author UI (getMultiAuthorView), apps that surface conflict-resolution prompts (onContributionHidden + worked example §8.5).

OpenFileTarget construction takes a syncMode option that governs op emission and acceptance — i.e., which ops broadcast outward and which inbound ops apply locally. These are policy choices on top of the basic Target contract; they don’t change the contract itself, but they fundamentally change UX.

type SyncMode =
| 'collaborative' // bidirectional; full collab. Default.
| 'follow-leader' // receive from designated peer(s); local ops stay local.
| 'spectator'; // receive from anyone; local ops stay local.
createOpenFileTarget({
syncMode: 'spectator',
upstream: { authors: ['lichess:player1', 'lichess:player2'] },
// ... identity, transport hookup, etc.
});

collaborative (default):

  • Local ops broadcast outward.
  • Inbound ops from any authenticated peer apply.
  • Standard CRDT collaboration. Coach-collab study, study-group sessions.

follow-leader:

  • Local ops are applied locally only; never broadcast.
  • Inbound ops apply only if op.opId.author is in upstream.authors.
  • Use case: student watching coach. Student can analyze locally (their ops affect only their replica); coach’s mainline moves stream through.
  • Cursor relay is orthogonal (and app-level): the coach’s app broadcasts cursor positions over the wire; the student’s app calls game.goToMove(coachNodeId) on receive. Target doesn’t carry cursor.

spectator:

  • Local ops are applied locally only; never broadcast.
  • Inbound ops from any authenticated peer apply (no upstream restriction).
  • Use case: lichess-style spectator on a live game. The user sees the played game grow in real time; their local analysis (variations, comments) stays private to them.

Privacy contract: in follow-leader and spectator, the onOpEmitted hook still fires for local-only ops (so apps can log / persist them) but the transport layer is configured to NOT forward them to peers. This is a transport-policy concern; the op layer just provides the hook.

Switching modes at runtime is not in scope for v1. A user who wants to switch from “watching” to “participating” reconstructs their target in the new mode (typically by reloading or re-joining the session).

Read-only vs. write-allowed UX. A spectator target is read-from- peers but write-allowed-locally. The “looks like read-only” UX (hiding the toolbar’s edit affordances) is an app-level choice — Tabia’s Game API stays uniform regardless. Apps that want to disable analysis entirely just hide the affordances; the underlying capability exists.

Permission enforcement. This spec doesn’t enforce who can do what at the protocol level — that’s identity / auth territory. A peer in collaborative mode could still emit ops that affect anyone’s contributions; cryptographic signing + acceptance policy at the transport layer is how real systems handle this. Defer to a future identity-and-trust spec.


The canonical archival format for an OpenFile session is PGN (see README). The chess content authored in any session — moves, comments, NAGs, drawables, per-author attribution — fits inside standard PGN. target.exportToPgn() (delegating to Tabia’s getPgn) is the right “save this study” path; the resulting file is portable to any chess tool that reads PGN.

The serialization formats described below — op log and snapshot — are operational artifacts for live sessions, not the canonical storage. They exist to support:

  • New peers catching up to an in-progress session quickly (snapshot)
  • Audit / undo within a session’s lifetime (op log)
  • Resume an in-progress edit session across a relay restart

For “save this study as a file the user owns,” produce a PGN. For “keep my live editing state on the relay so I can resume editing tomorrow,” use a snapshot. Different concerns; different formats.

Two operational serializations, for live-session needs:

Op log archive. The complete sequence of signed ops, in HLC order, preserved verbatim. Replayable from scratch by feeding into a fresh target.

  • Pros: complete fidelity. Every signature, every author attribution, every HLC. Audit / undo / history navigation all possible.
  • Cons: grows linearly with session activity. A 6-month-old session with 100k ops is multiple megabytes. Not the archival format — it’s the operational record of a live session.

Snapshot. Derived state at a specific HLC: the resolved tree, record, delegations, applied-set summary. Doesn’t carry the path that led there.

  • Pros: bounded size (proportional to current state, not history). Fast to restore from. Right thing for new-peer catch-up.
  • Cons: loses history. Can’t replay; can’t reconstruct “Alice said X at HLC 47 then deleted it at HLC 92.” Can’t validate ops below the snapshot’s watermark — they’re assumed already applied.

Together. The standard pattern: periodic snapshots + op-log tail since the last snapshot. Apps restore from latest snapshot + apply the tail. Balances fidelity and storage cost.

Use caseBest format
User saves study to disk / shares with anyonePGN (getPgn())
New peer joins long-running session, needs to catch up fastSnapshot (+ tail)
Live transport: caught-up peer joinsSnapshot + tail since snapshot HLC
Resume in-progress edit session across relay restartOp log
Storage-constrained relaySnapshot only
Undo/redo within a sessionOp log

A sequence of signed ops in canonical JSON, one per line — newline-delimited JSON (JSONL). UTF-8 encoded.

{"opId":{"author":"...","seq":1},"hlc":1234,"signature":"...","type":"addNode","parentNodeHash":"...","san":"e4"}
{"opId":{"author":"...","seq":2},"hlc":1289,"signature":"...","type":"addSegment","nodeHash":"...","text":"good move"}
{"opId":{"author":"...","seq":3},"hlc":1305,"signature":"...","type":"setMainChild","parentNodeHash":"...","childNodeHash":"..."}

Each line is a single op, serialized exactly as it would be on the wire — same canonical-JSON form used for signing (per identity spec §3.3). The file format is just “every op, append-only, newline- separated.”

Properties:

  • Append-only friendly. Apps can write incrementally as ops apply; no rewrite of prior content.
  • Streamable. A reader can process line-by-line without loading the whole file into memory.
  • Wire-format-compatible. The bytes on disk are the bytes on the wire. A persisted op log can be replayed into a transport stream and vice versa.
  • Ordering. Ops appear in apply order (which respects HLC causality after buffer drains). Re-applying in the same order is always safe; ops have idempotent opIds anyway.
  • No checksum / framing. Apps that want integrity-checking at the file level add their own envelope. For OpenFile’s purposes, each op carries its own signature; a corrupt op fails verification on re-load.

A single canonical-JSON document. Extends the sketch from §6.3:

{
"version": "openfile-snapshot-v1",
"generatedAt": 1730412345000123,
"watermark": 1730412345000123,
"sessionMeta": {
"rootKeys": ["MCowBQ...root1"],
"startFen": "rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1"
},
"delegations": {
"<delegationHash>": {
"type": "delegate",
"opId": { "author": "MCowBQ...root", "hash": "<delegationHash>" },
"hlc": { "physical": 1234, "logical": 0 },
"iss": "MCowBQ...root",
"aud": "MCowBQ...alice",
"sub": null,
"cmd": ["/play", "/invite"],
"proof": "<parentHash>",
"signature": "<sig>"
}
},
"state": {
"nodes": { "<nodeHash>": { /* node record */ } },
"segments": [ /* alive comment segments */ ],
"drawables": [ /* alive drawables */ ],
"scalars": { /* per-author per-key scalars */ },
"nagContributions": { /* per-author per-node NAG sets */ },
"mainChildRegisters": { /* per-parent LWW */ },
"nodeDeletedRegisters": { /* per-node LWW */ },
"opDeletedRegisters": { /* per-op LWW */ }
},
"appliedSetSummary": {
"perAuthorMaxSeq": { "MCowBQ...alice": 47, "MCowBQ...bob": 92 }
}
}

Field notes:

  • version: schema-version tag. Future snapshot formats bump this and OpenFile rejects unknown versions cleanly.
  • generatedAt: the HLC at which the snapshot was taken. State reflects ops with hlc ≤ generatedAt.
  • watermark: the causal-stability watermark at snapshot time. Ops with hlc < watermark are settled; below-watermark ops can be GC’d. Often equal to generatedAt but can be lower.
  • sessionMeta: immutable session metadata (root keys, starting FEN). Apps don’t need to send this elsewhere; it’s the same for all peers of the session.
  • delegations: a content-addressed map of all delegation ops needed to verify ops between watermark and generatedAt. Includes the root delegation (with proof: null) and any sub-delegations whose audiences may have authored ops in that window. Below-watermark delegations can be omitted under the same logic as trust ops in v0.5 — the rejected-below-watermark rule per §4.3 step 1 means we never need to verify against historical states below the watermark. The snapshot consumer uses these to populate state.delegations for chain-walking verification (identity v0.2 §4.3).
  • state: the derived multi-author state. The exact internal layout is implementation-specific; what matters is that a target can deterministically reconstruct its derived state from this.
  • appliedSetSummary: per-author max seq, used by idempotence machinery. A new peer with this summary will reject duplicate ops whose seq is below the summary even if those opIds aren’t individually stored.

Properties:

  • Single document, atomic write. No partial-snapshot states.
  • Self-contained restore. A snapshot alone is enough to construct a working target; no companion files needed.
  • Bounded size. Grows with current state, not history. A snapshot of a 6-month-old session is roughly the same size as a snapshot of a 1-day-old one at similar activity levels.
interface OpenFileTarget {
// ... existing Target methods ...
/** Export the op log as JSONL bytes (UTF-8). All ops applied so
* far, in apply order. */
exportOpLog(opts?: {
sinceHlc?: number; // omit ops with hlc < this
untilHlc?: number; // omit ops with hlc > this
}): Uint8Array;
/** Export a snapshot of state at the current HLC, or at a
* specified HLC if the implementation supports historical
* snapshots. */
exportSnapshot(opts?: {
atHlc?: number; // default: current HLC
}): Uint8Array;
}

The sinceHlc / untilHlc bounds on exportOpLog are how apps implement the “snapshot + tail” pattern: snapshot at HLC N, then exportOpLog({ sinceHlc: N }) to get the tail.

Implementations may not support arbitrary historical snapshots (reconstructing state at an old HLC requires either keeping intermediate snapshots or replaying the log). v1 minimum: support exportSnapshot() with no args (current HLC). v2 may add historical snapshot support.

The createOpenFileTarget constructor accepts two new initialization options. They’re mutually exclusive with a fresh-session construction.

createOpenFileTarget({
// Fresh session — no persistence input:
sessionMeta: { rootKeys, startFen },
// OR restore from op log only — full replay:
opLog: Uint8Array,
// OR restore from snapshot, optionally with op-log tail:
snapshot: Uint8Array,
opLogTail?: Uint8Array,
// Common options regardless of source:
signer, verifier, transport, syncMode, ...
});

Restore from op log. Parses each line as a signed op, applies in order through the standard pipeline. Verification (signatures, delegation chain walk per identity v0.2 §4, chess-data validation) runs on every op — same as receive-from-wire. Slow for large logs but provides complete fidelity.

Restore from snapshot. Parses the snapshot JSON, hydrates state directly (no per-op application). The target’s HLC starts at snapshot.generatedAt; state.delegations is populated from snapshot.delegations; session metadata is snapshot.sessionMeta.

Restore from snapshot + opLogTail. Hydrates from snapshot, then applies the tail ops (those with hlc > snapshot.generatedAt). This is the standard “fast catch-up” pattern.

Restore validation. Snapshots aren’t signed (they’re derived state, not author-emitted ops). Apps should retrieve them from trusted sources (your own storage, your own server). A snapshot from an untrusted source could lie about derived state. For end-to-end verification, restore from op log only — every op is signature- verified during replay.

The transport spec (forthcoming) defines the wire format for ops in transit. Both wire format and persistence format use the same canonical-JSON encoding for individual ops. Implications:

  • A persisted op log can be replayed directly into a transport’s outbound stream.
  • A received op from the transport can be appended directly to a persisted op log (same bytes).
  • A snapshot retrieved from a peer (transport spec §X) is the same bytes as a snapshot loaded from local storage.

One format, three contexts (wire, log, snapshot). Single source of truth for what an op “looks like” on the wire and at rest.

Per the persistence-is-storage-agnostic principle established in Tabia (getPgn() returns a string; apps choose storage):

  • Where bytes live. localStorage, IndexedDB, server database, S3, filesystem, in-memory only, encrypted-on-disk — all app’s call.
  • When to snapshot vs. log. Snapshot frequency, op-log truncation policy, compaction strategy.
  • Collection management. Listing the user’s sessions, dedup across sources, search. (Patterns in Tabia’s record.js apply equally to OpenFile sessions — fingerprinting, source merge, kind classification — but the wiring is app-side.)
  • Sync between local and server. If an app maintains both a local IndexedDB cache and a server-side authoritative store, the conflict-resolution policy between them is app territory.
  • Encryption at rest. If user data sensitivity requires it.
  • Wire-format envelope for archive interchange. If apps share archive files between users, they may want a wrapping format (zip + metadata, signed manifest, etc.); the op log alone is sufficient for OpenFile.
import { createOpenFileTarget } from 'openfile';
// — Save flow —
async function saveSession(target, sessionId) {
const snapshot = target.exportSnapshot();
const tail = target.exportOpLog({ sinceHlc: getLastSnapshotHlc(sessionId) });
await db.put(`session:${sessionId}:snapshot`, snapshot);
await db.put(`session:${sessionId}:tail`, tail);
setLastSnapshotHlc(sessionId, parseHlcFromSnapshot(snapshot));
}
// — Resume flow —
async function resumeSession(sessionId) {
const snapshot = await db.get(`session:${sessionId}:snapshot`);
const tail = await db.get(`session:${sessionId}:tail`);
return createOpenFileTarget({
snapshot,
opLogTail: tail,
signer: app.getSigner(),
verifier: createWebCryptoVerifier(),
// ... transport / syncMode / etc.
});
}

The library handles serialization. The app handles storage and session-list management. Same architecture as Tabia.

A session manifest is an owner-signed binding between a session’s trust frame and a canonical PGN. It is the L2 in the three-layer model:

LayerFormatProvides
L1PGNCanonical chess content. Human-readable. Survives the network.
L2Session manifestTrust frame + watermark + pgnHash binding. Owner-signed.
L3Op log (above watermark)Live ops. Verified per-op via delegation chains.

A manifest replaces the snapshot role in the “snapshot + tail” pattern when the owner is available to sign. The unsigned snapshot is the legacy form, still supported, but the recommended path for new session storage is manifest + PGN + op-log tail.

Manifest shape (v4, canonical JSON, fields in spec order):

{
"version": "openfile-manifest-v4",
"ownerPubkey": "MCowBQ...owner",
"sessionMeta": {
"ownerPubkey": "MCowBQ...owner",
"startFen": "rnbqkbnr/.../w KQkq - 0 1"
},
"perms": [
{ "aud": "*", "cmd": ["/comment"] },
{ "aud": "...alice", "cmd": ["/play", "/grant"] },
{ "aud": "...bob", "cmd": ["/play"] }
],
"watermark": { "physical": 1730412345000, "logical": 0 },
"generatedAt": { "physical": 1730412345000, "logical": 0 },
"pgnHash": "<hex sha256 of utf-8 pgn bytes>",
"pgnLength": 4096,
"signature": "<Ed25519 sig over canonical(manifest minus signature)>"
}

Flat perms form. The manifest stores the effective trust frame as a flat array of { aud, cmd } entries — a snapshot of the GRANTED entries in state.permsLww at watermark time. The aud can be either a real pubkey or "*" (the wildcard for defaults).

The bootstrap op (owner self-grants /) is reflected as a perms entry: { aud: ownerPubkey, cmd: ['/'] }. Manifest verification re-establishes ownership via this entry on hydration.

Why perms snapshot, not op log: the snapshot represents the current effective state of the LWW registers at watermark time. Below-watermark op envelopes (grant/revoke) are GC-eligible — their effects are preserved in this snapshot. Any per-link or per-grant cryptographic verification was discharged at apply time on the owner’s replica; the owner’s manifest signature attests to the result.

View-only entries are excluded. Entries whose cmd reduces to pure read caps (e.g., ['/view']) carry no trust-frame information — /view doesn’t gate any op. Skipping them keeps the manifest size bounded by the contributor count, not the viewer count.

Signature scope. The owner signs canonicalize(manifest − signature). This binds together the trust frame and the pgnHash: an attacker cannot swap in a different PGN, mutate perms, or claim ownership without invalidating the signature.

Verification. Anyone holding a verifier + the PGN bytes can verify:

  1. pgnLength matches pgnBytes.length
  2. sha256(pgnBytes) matches manifest.pgnHash
  3. manifest.ownerPubkey == manifest.sessionMeta.ownerPubkey
  4. Ed25519.verify(manifest.signature, canonical(manifest − signature), ownerPubkey)

A single Ed25519 verify, no per-link recursion. The owner’s signature is the trust anchor.

Loading a manifest installs each perms entry as a GRANTED LWW register in state.permsLww. Subsequent ops above the watermark apply normally and may update these registers via LWW resolution.

Why pgnHash and not embed the PGN? Apps that already store the PGN elsewhere (the user’s filesystem, a chess server, GitHub) avoid duplicating bytes. A manifest is ~few KB; a study’s PGN can be arbitrarily large.

Watermark semantics. watermark is the HLC below which the manifest asserts authority: all valid ops with hlc ≤ watermark are captured by the PGN. Ops above the watermark are in L3 (the live op- log tail) and verified per-op normally. An owner publishing a new manifest may GC L3 ops below the new watermark.

OpenFileTarget export API (extends §10.4):

interface OpenFileTarget {
/** Build + sign a session manifest. Requires the owner's signer
* (must be in rootKeys) and the canonical PGN text. */
exportManifest(opts: {
ownerSigner: Signer;
pgnText: string;
}): Promise<Manifest>;
}

Worked example: publish + resume via manifest.

import { serializeManifest, parseManifest, verifyManifest } from 'openfile';
import { createGameFromTarget } from 'tabia';
// — Owner publishes —
async function publishSession(target, ownerSigner, sessionId) {
const pgnText = createGameFromTarget(target).getPgn();
const manifest = await target.exportManifest({ ownerSigner, pgnText });
await db.put(`session:${sessionId}:manifest`, serializeManifest(manifest));
await db.put(`session:${sessionId}:pgn`, new TextEncoder().encode(pgnText));
// Optional: prune op log below manifest.watermark
}
// — Joiner resumes —
async function joinFromManifest(sessionId, verifier) {
const manifestBytes = await db.get(`session:${sessionId}:manifest`);
const pgnBytes = await db.get(`session:${sessionId}:pgn`);
const manifest = parseManifest(manifestBytes);
const { valid, error } = await verifyManifest(manifest, pgnBytes, { verifier });
if (!valid) throw new Error(`manifest invalid: ${error}`);
// Reconstruct target: trust frame from manifest, content from PGN.
// Content hydration uses the same merge path as PGN imports —
// see merge-games.js for the mechanism.
// (See identity §10.4 for the joiner-side recipe.)
}

§11 What’s deliberately NOT in the spec

Section titled “§11 What’s deliberately NOT in the spec”

Out of scope, deferred:

  • Wire format — JSON/binary, schemas, versioning. Transport spec.
  • Identity / op signing / trust networks — cryptographic signing of ops + acceptance policy. Future identity-and-trust spec.
  • Authorization — who’s allowed to emit which ops. Identity spec.
  • Presence indicators — peer cursor sharing, “Alice is looking here” UX. App or future presence spec.
  • History / replay UX — “rewind to before Bob’s mass-delete.” App concern with op-log access via §9.6.
  • Sync protocol details — transport spec.
  • Membership management — transport spec.
  • Causal stability detection — transport spec.
  • Bounded buffer GC — what to do if a peer’s buffer of not-yet-resolvable ops grows without bound. v2+ / transport.
  • Rejection propagation to emitting peer — the op layer rejects silently; transport may notify. Transport + identity specs.

  1. Header-editing API on OpenFileTarget. Tabia’s Target contract leaves setHeader / removeHeader as optional methods (not yet shipped). When Tabia adds them, OpenFileTarget will implement as per-author LWW on a headerByAuthor[author][key] structure, with aggregation rules analogous to NAG resolution. Defer until Tabia ships the surface.

  2. Snapshot frequency. When does the transport request / emit snapshots, and how often does the op layer advance watermarks? Likely on watermark advance or periodically. Defer to the transport spec.

  3. GC’d anchor revival. A peer that’s been offline long enough may emit ops referencing anchors that have been GC’d locally. The receiver responds with “fetch snapshot” — the catch-up protocol lives in the transport spec. Rare in practice; real in long-lived systems.

  • HLC over plain Lamport. Both work for correctness; HLC adds wall-clock approximation (UI affordances like “3 minutes ago”) for modest implementation cost. Best-in-class for collab. (§1.3)
  • setMainChild stays separate from addNode. Variation-by- default matches Tabia’s existing UX and industry convention (ChessBase, lichess). New move at a non-leaf node becomes a sibling; promoting to mainline is an explicit second step.
  • Same-content addNode tiebreak. Lowest-HLC affirmer wins for display owner (variation labeling); ties broken by author lexicographic. Standard distributed-systems tie-break. Apps may override the display choice. (§1.7)
  • Engine-as-author convention dropped. Engine analysis is a tool result; the user is the author. Live engine state lives on Game’s engine slice; persisting analysis to the record uses standard author=user ops. The “this came from an engine” signal lives in content (comment text, NAGs) or annotation metadata — not in the author handle.
  • Illegal-move emission and acceptance. Receivers (including the emitting peer) validate locally and reject illegal SAN ops. The op layer doesn’t trust the emitter; the data-integrity check is re-run on every apply. (§3.1, §4.3)

Deferred to the identity / trust / signing spec

Section titled “Deferred to the identity / trust / signing spec”
  • Author spoofing prevention. Currently authors are opaque strings, unverified. Real collab needs cryptographic signing of ops + acceptance policy at the receive side. This is the load- bearing reason for a dedicated identity spec.
  • Identity unification. Cryptographic alias claims linking “alice@old-handle” to “alice@new-handle.” Lives in the identity spec; the op layer just sees opaque author strings.
  • Trust networks / permissions. “Which authors do I trust?” / “Who is allowed to emit which ops?” Apps in collaborative sync mode today trust all peers; the identity spec adds gating.
  • Runtime mode switching. §9.7 says modes are construction-time. Hot-switching (e.g., “promote from spectator to participant”) is tangled with identity/trust (does the peer’s promoted identity count for retroactive op acceptance?). Defer to the same spec.
  • Op rejection propagation. When a receiver rejects an op (illegal SAN, invalid signature, etc.), does the emitter learn about it? Notification is transport-layer policy; the response contract is identity-aware. Defer to identity + transport specs.

This spec is OpenFile’s foundation. The Tabia-side prerequisite (Target abstraction extraction) has landed — see commit f3dd06f in Tabia/ and the v0.2 Target spec. That means OpenFile implementation is unblocked.

After review:

  1. Implement the op layer — vocabulary (§3), apply pipeline (§4), applied-set, buffer, HLC clock, visibility computation (§5), GC machinery (§6). Pure in-memory, no transport.
  2. Implement OpenFileTarget — wrap the op layer to comply with the v0.2 Target contract, per §9. Conformance verified by running Tabia’s existing test/local-target.test.js against the OpenFileTarget instance.
  3. Implement persistence (§10) — exportOpLog, exportSnapshot, constructor restore paths. Pure serialization; no I/O. Apps wire storage.
  4. Implement OpenFile’s mergeGames — parse N PGNs to op streams, apply via op layer. ACI-correct by construction.
  5. Implement sync modes (§9.7) — emission policy + acceptance policy at construction time. Local-only ops in spectator / follow-leader modes.
  6. Tabia integration smoke test — run a Game backed by OpenFileTarget through Tabia’s full Game test suite. Pass means the seam is honored.
  7. Transport spec (sibling doc) — wire protocol, sync, membership, watermark, gossip protocol.
  8. Transport layer — WebSocket + WebRTC implementations; ship ops between peers via OpenFile target’s receiveOp / onOpEmitted hooks.

Steps 1-6 can land without any transport — single-process collaboration (e.g., two OpenFileTarget instances in the same JS context sharing ops via a direct event bus) is achievable as a milestone. The transport spec (steps 7-8) unlocks real networked deployments.

Tabia itself is unchanged after the Target refactor. New apps build against createGame (single-user) or createGameFromTarget (collab) without needing to know whether their target is Local or OpenFile.