Identity & trust (spec)
Status: draft v0.4.1 (2026-05-19) Prerequisite reading: op vocabulary — defines the op envelope, HLC, causal-delivery rules, and the validate-on-receive posture this spec extends. Heritage: v0.2 adopted UCAN-style delegation chains; v0.3 collapsed to flat effective grants; v0.4 further unifies the model around LWW registers + symmetric grant/revoke primitives + a wildcard sentinel for defaults. Each step has reduced protocol surface while preserving cryptographic properties.
What changed in v0.4.1
Section titled “What changed in v0.4.1”A correctness patch to v0.4. The earlier v0.4 spec validated mutation
ops against the current state of permsLww at apply time, which is
not CRDT-correct: perm ops and mutation ops aren’t commutative, so
different receive orders led to different derived states.
v0.4.1 fixes this by:
- Per-register history in
permsLww(above the stability watermark; collapsed below). Memory cost is small — typically 0–3 entries per register at any time. - HLC-indexed cap lookup via
effectiveCapsAt(K, H)— every mutation op M is checked against caps at M.hlc, not current state. - Tentative apply + tombstone finalization — mutation ops apply optimistically (instant UX) but are tagged tentative until the stability watermark advances past their HLC. If a late-arriving perm op invalidates a tentative mutation, it’s tombstoned.
In the common case (no perm/mutation concurrency on the same pubkey), mutation ops apply instantly and never reverse — identical to naive optimistic apply. The edge case (concurrent perm op) gets a rare “vanishing move” UX, which is the price of full CRDT correctness.
See §1.0, §1.0.2, and §4.5 for the mechanics.
What changed in v0.4
Section titled “What changed in v0.4”v0.3 had a single delegate op that monotonically added capabilities,
plus a separate defaultCaps field on the manifest, plus a rootKeys
Set with implicit full caps. Three different mechanisms; three different
concepts.
v0.4 unifies them. The trust frame is a single LWW register family
keyed by (pubkey-or-"*", cmd), populated by exactly two op types:
grant— sets a register to GRANTED.revoke— sets a register to REVOKED.
Resolution is straight LWW per register: highest HLC wins; ties broken by author. The capability lookup is:
effectiveCaps(pubkey): 1. For each cmd: check (pubkey, cmd) register. GRANTED/REVOKED is decisive. 2. If UNSPECIFIED: check ("*", cmd) register as fallback. 3. Still UNSPECIFIED: cmd is not in caps.No rootKeys field. No defaultCaps field. No implicit caps anywhere
in effectiveCaps. The bootstrap of authority is the first op of the
session — a self-grant signed by sessionMeta.ownerPubkey. Joiners
validate this op via an inductive base case: when the LWW state is
empty, an op asserting ownership is accepted iff its iss matches
sessionMeta.ownerPubkey. From there, normal validation takes over.
Surface changes
Section titled “Surface changes”Op types: delegate → split into grant + revoke. Both target a
real pubkey or the wildcard "*". Both use sequential {author, seq}
opIds (no content-addressed hash form anywhere).
Capabilities: v0.3’s /invite is renamed to /grant for naming
coherence with the grant op. New /revoke capability gates the
revoke op, orthogonal to /grant. The capability lattice is
otherwise unchanged.
Revoke gating (the strict-superset rule): an issuer can revoke from a target iff either (a) issuer == target (self-revoke, modulo attenuation) or (b) issuer’s effective caps are a strict superset of target’s. This prevents peer-vs-peer adversarial revokes while enabling legitimate top-down moderation.
sessionMeta: rootKeys: Set<pubkey> → ownerPubkey: pubkey. Single
bootstrap signer, named explicitly. Multi-owner sessions express by
granting / to additional pubkeys post-bootstrap.
Time bounds dropped from envelope: nbf / exp removed. Time-bound
permissions express via app composition (emit grant now, schedule
revoke later). No protocol-level expiry.
Manifest v4 format: carries a snapshot of LWW registers (including
wildcard entries) rather than separate grants + defaultCaps fields.
View-only entries still excluded from the snapshot (they don’t gate
any op).
Stability watermark + heartbeats: the protocol now distinguishes between the manifest watermark (owner-attested archival anchor) and the stability watermark (automatic GC anchor derived from observed peer progress). The stability watermark drives continuous op-log GC. Heartbeats are introduced as a lightweight transport-level pulse so silent connected peers (spectators) don’t pin the watermark.
What survives intact
Section titled “What survives intact”- Capability lattice (
/,/view,/comment,/play,/moderate,/grant,/revoke) and attenuation invariant. - Per-op Ed25519 signatures — the cryptographic backbone is unchanged.
- The Powerline pattern (anonymous-via-link). Link keys are ordinary pubkeys in the grants frame; per-tab grants verify the same way as anything else.
- The manifest’s role as an owner-signed archival artifact binding a PGN to a trust frame.
What changed in v0.3
Section titled “What changed in v0.3”v0.2 stored authority as a chain of signed delegations: each
delegate op carried a proof field referencing its parent, and
verification walked the chain back to a rootKey. v0.3 dropped the chain
in favor of flat effective grants, observing that the chain was
redundant under our threat model. v0.4 further refines this; see “What
changed in v0.4” above.
What changed in v0.2
Section titled “What changed in v0.2”(retained for historical context — superseded by later revisions)
v0.1 had a flat trust model: a per-session trust store. v0.2 replaced this with a UCAN-style delegation-chain model.
Background
Section titled “Background”OpenFile is a many:1 chess collaboration layer. Multiple users author moves, comments, and annotations against a shared CRDT game record. Every contribution is cryptographically signed by its author so peers can verify what they receive rather than trust unverified claims.
Signatures are an in-flight verification mechanism, not a storage artifact. OpenFile’s persistent output is a PGN (see README). Signed ops exist while a session is live so peers can verify each other’s contributions and converge on shared state. The signature data is not retained in the produced PGN — once the document is settled, the chess content stands alone. A snapshot served to a late joiner during a live session carries an envelope-level signature as a trust handoff for the in-flight transition, but archival storage is plain PGN.
The v0.1 spec stopped at “is this signer authorized?” — a flat yes/no question. Real chess workflows want gradations: a study shared with spectators (read-only), a coaching session where students may comment but not move pieces, a tournament broadcast where arbiters can moderate but most viewers can only watch.
The v0.2 spec adds the capability model that expresses these gradations cryptographically. The shape is borrowed from UCAN (see UCAN spec linked above); the adaptations are for our CRDT-and-CRDT-only context.
§1 Foundational decisions
Section titled “§1 Foundational decisions”These are locked. The rest of the spec depends on them.
1.0 The LWW-register trust frame (v0.4 core)
Section titled “1.0 The LWW-register trust frame (v0.4 core)”The session maintains a permissions register family, with each register storing recent history above the stability watermark:
permsLww: Map<(pubkey | "*", cmd), { aboveWatermark: Array<{ state: 'granted' | 'revoked', hlc, opId }>, belowWatermarkLatest: { state, hlc, opId } | null}>Each (pubkey, cmd) pair (or ("*", cmd) for defaults) is an
independent LWW register with watermark-collapsed history. Two op
types update these registers:
grant(aud, cmd)— appends a GRANTED entry toaboveWatermark.revoke(aud, cmd)— appends a REVOKED entry toaboveWatermark.
Entries within aboveWatermark are ordered by HLC; ties broken by
author lexicographically.
When the stability watermark (§9b) advances past an HLC H, entries in
aboveWatermark with hlc ≤ H are collapsed via LWW into
belowWatermarkLatest. This bounds memory — only the recent active
window keeps full history.
Why history rather than just the latest value? Permission ops and mutation ops aren’t commutative: a perm op can retroactively affect the validity of a mutation op at a higher HLC. To resolve this deterministically across all peers (§4.5), capability lookups must be HLC-indexed (§1.0.2), which requires walking the history.
History size is bounded in practice — permission changes are rare; typical sessions have 0–3 entries per register at any time.
1.0.2 Capability lookup
Section titled “1.0.2 Capability lookup”Capability lookups take an HLC parameter — “what were K’s caps at HLC H?”:
effectiveCapsAt(K, H): caps = ∅ for each cmd in CMD_LATTICE: userReg = permsLww.get((K, cmd)) userLatest = lookupEntry(userReg, H) if userLatest?.state == GRANTED: caps.add(cmd); continue if userLatest?.state == REVOKED: continue wildReg = permsLww.get(("*", cmd)) wildLatest = lookupEntry(wildReg, H) if wildLatest?.state == GRANTED: caps.add(cmd) return caps
lookupEntry(register, H): # Find latest entry in aboveWatermark with hlc ≤ H candidate = lastEntryWithHlcAtMost(register.aboveWatermark, H) if candidate: return candidate # Fall back to the collapsed below-watermark state return register.belowWatermarkLatest # always has hlc ≤ watermark ≤ HlastEntryWithHlcAtMost returns the highest-HLC entry with hlc ≤ H.
The ≤ is inclusive — a perm entry at exactly H counts (the
“permission op wins the seam” rule, §4.5).
Per-user state takes priority over wildcard. The wildcard is a fallback
when per-user state is UNSPECIFIED at H. No rootKeys, no
defaultCaps, no implicit caps anywhere.
Authority-check sites use effectiveCapsAt(op.iss, op.hlc) — caps
at the moment of the op’s emission, not current state. This is what
makes the model CRDT-correct (§4.5).
1.0.1 Bootstrap — the first op
Section titled “1.0.1 Bootstrap — the first op”How does any pubkey get caps in the first place if every cap requires a grant from someone with authority? The bootstrap is the inductive base case of authority lookup.
Convention: the very first op of a session MUST be a grant op
that is:
- signed by
sessionMeta.ownerPubkey, - targets
sessionMeta.ownerPubkey(self-grant), - with
cmd: ['/'](full caps).
A receiver validates this op specially: when permsLww is empty AND
the op meets these structural criteria AND its signature verifies
against sessionMeta.ownerPubkey, the op is accepted without needing
any prior grant. After it applies, the owner is in permsLww with /
and all subsequent grants follow normal validation.
This is not a “special exception” — it’s the leaf case of the same recursive authority lookup, where the recursion bottoms out at sessionMeta (the trust anchor delivered alongside the session identity) when no prior op exists to consult.
1.0.2 The two op types
Section titled “1.0.2 The two op types”grant:
type Grant = OpEnvelope & { type: 'grant'; iss: string; // issuer pubkey (== opId.author) aud: string | "*"; // audience pubkey, or "*" for defaults cmd: string[]; // capabilities being granted};Apply rule:
- Verify Ed25519 signature against
iss. - If
permsLwwis empty: bootstrap path (see §1.0.1). - Else: issuer must hold
/grantANDcmd ⊆ effectiveCaps(iss). - For each c in cmd: set
(aud, c)register to GRANTED@op.hlc.
revoke:
type Revoke = OpEnvelope & { type: 'revoke'; iss: string; // issuer pubkey (== opId.author) target: string | "*"; // pubkey whose caps to remove, or "*" cmd: string[]; // capabilities being revoked};Apply rule:
- Verify Ed25519 signature against
iss. - Issuer must hold
/revokeANDcmd ⊆ effectiveCaps(iss)(you can only revoke caps you currently hold). - Strict-superset gate (see §5.3):
- Self-revoke (iss == target): always allowed (subject to step 2).
- Other-revoke:
effectiveCaps(iss) ⊋ effectiveCaps(target). Equal or incomparable cap sets cannot revoke each other.
- For each c in cmd: set
(target, c)register to REVOKED@op.hlc.
1.0.3 Causal-delivery buffering
Section titled “1.0.3 Causal-delivery buffering”An op whose iss has no caps yet (empty effectiveCaps) AND isn’t
performing bootstrap is buffered on key iss:<iss>. When any
later op establishes caps for that pubkey, the buffer drains.
This handles the natural race where a downstream op (e.g., Bob’s first chess move) arrives before the grant op that authorized Bob.
1.1 Every op is signed; identity is required for emit, never for receive
Section titled “1.1 Every op is signed; identity is required for emit, never for receive”The single load-bearing rule (unchanged from v0.1):
To emit an op into the session, a peer must hold a private key whose corresponding public key has been granted authority via the session’s delegation DAG. Each op carries an Ed25519 signature over its canonical serialization, verified on receive against the public key in
op.opId.author.
Implications:
- Anonymous viewing is the universal default. A peer that connects, receives ops, and applies them locally needs no key, no signature, no delegation. Identity gates the emit path; the receive path is open.
- Local-only ops still get signed. Spectator / follow-leader peers emit local-only ops that never cross the wire; they’re still signed by an ephemeral key for code-path uniformity.
1.2 Signature scheme is Ed25519, fixed
Section titled “1.2 Signature scheme is Ed25519, fixed”(Unchanged from v0.1.) Ed25519 (RFC 8032) is the protocol’s signature scheme. No alternatives. Reasoning: small wire footprint, fast, Web Crypto / libsodium / stdlib support everywhere, no misconfiguration surface.
1.3 Per-op signing, not session keys
Section titled “1.3 Per-op signing, not session keys”(Unchanged from v0.1.) Each op signed individually by its emitter’s identity key. Session-key indirection and batch signing are deferred.
1.4 Author field = public key (base64)
Section titled “1.4 Author field = public key (base64)”(Unchanged from v0.1.) op.opId.author is the base64-encoded Ed25519
public key (44 chars). The “author IS the key” — no fingerprint
indirection.
1.5 Identity does NOT imply user account
Section titled “1.5 Identity does NOT imply user account”(Unchanged from v0.1.) A keypair is a protocol-level identity, not a user account. Apps map accounts to keypairs however they want.
1.6 Authority is a capability bundle, governed by LWW grants
Section titled “1.6 Authority is a capability bundle, governed by LWW grants”Every signed op carries an implicit assertion: “I have the capability required for this op.” Validation looks up the issuer’s effective caps in the LWW grants frame. Owner authority bootstraps as the inductive base case (first op self-grants
/); all other authority flows from explicitgrantops. Revocation is symmetric:revokeops set registers to REVOKED via the same LWW resolution.
Three rules:
- Attenuation. Both grant and revoke require the issuer to hold every cmd they’re acting on. You can’t grant or revoke what you don’t currently have.
- Authority lookup is a single map read. No chain walk; no
recursion.
effectiveCapsresolves via per-user register, with fallback to wildcard, both LWW. - Strict-superset on other-revoke (§5.3). Equal or incomparable cap sets cannot revoke each other.
1.7 Ownership is transferable, not immutable
Section titled “1.7 Ownership is transferable, not immutable”Owner status comes from the bootstrap grant op (owner → owner, cmd
/). Like any other grant, it can be revoked — and the owner can
transfer ownership by granting / to a successor and self-revoking.
This is materially different from v0.3’s rootKeys model, where root
keys had implicit caps that couldn’t be removed. In v0.4 everything
lives in permsLww; ownership is a normal LWW register that obeys
the same rules as everything else.
Self-revocation is always allowed (subject to attenuation: you can only revoke caps you currently hold).
Other-revocation is gated by strict-superset (§5.3). One co-owner
cannot revoke / from another co-owner; equal cap sets cannot
revoke each other.
1.8 No time bounds in the envelope
Section titled “1.8 No time bounds in the envelope”Earlier drafts reserved nbf (not-before) and exp (expires) fields
for time-bounded permissions. v0.4 removes these.
Time-bound permissions express via app composition: the granting
app emits the grant op at T₀ and schedules a revoke op at T₁.
The protocol stays simple; apps get the same effect via standard
primitives.
§2 Layer boundaries
Section titled “§2 Layer boundaries”2.1 What this spec owns
Section titled “2.1 What this spec owns”- Wire format for signatures and public keys (Ed25519, base64).
- Canonical serialization of ops for signing.
- The
grantandrevokeop types — envelope, semantics, apply rules. - The capability lattice (§5) and per-op capability requirements (§6).
- Verification rules (§4): bootstrap + LWW grants + attenuation + strict-superset (for revoke) + capability gate.
- Stability watermark and heartbeats (§9b) — runtime GC mechanism.
- The Powerline pattern (§7) — anonymous-with-link bootstrap.
2.2 What apps own
Section titled “2.2 What apps own”- Key generation, storage, and recovery. Browser storage, hardware tokens, password-derived — every flavor is fine.
- Account authentication. OAuth / WebAuthn / etc. — protocol doesn’t care.
- Display name resolution.
pubkey → "Alice"lives in the app. - Role UX. Pickers like “Invite as Viewer / Commenter / Player” in the UI compose into the cmd-bundle delegations (§6).
- Link generation policy. When to mint new links, what capability bundles to attach, expiration policy — app-level.
2.3 What the transport spec owns
Section titled “2.3 What the transport spec owns”- Network-level peer identification.
- Channel security (TLS, certificate pinning).
- Message framing and wire encoding.
- Session-seed protocol — how the first peer establishes session metadata, how subsequent peers receive it via WELCOME.
- Op-log persistence at the relay — required for offline-host bootstrap (see transport spec v0.2 §5).
- Log replay — new joiners receive prior ops via a relay-mediated replay before they begin participating.
2.4 What’s coming in near-term follow-ups
Section titled “2.4 What’s coming in near-term follow-ups”revokeop (§11.1) — invalidates a previously-issued delegation. v0.3.- Time-bound enforcement (§11.2) —
nbf/expfields are reserved in v0.2; enforcement and skew handling in v0.3. - Policy language (§11.3) — UCAN-style jq-inspired predicates for constraining invocations. Deferred until a real use case arrives.
2.5 What’s deferred indefinitely
Section titled “2.5 What’s deferred indefinitely”- Identity unification. “WestPortalian and JohnBoyer are the same person.” Probably never for chess.
- DID URI scheme. Bare base64 pubkeys for now; can adopt
did:key:later for interop. - Cross-session credentials (“I’m an FM, prove role across sessions”). Federation-scale, far off.
§3 The op envelope
Section titled “§3 The op envelope”3.1 Signature field (unchanged from v0.1)
Section titled “3.1 Signature field (unchanged from v0.1)”Every op carries a mandatory signature field — base64-encoded
Ed25519 signature over the op’s canonical JSON serialization
(excluding signature itself).
3.2 The grant and revoke op types
Section titled “3.2 The grant and revoke op types”v0.4 replaces v0.3’s single delegate op with two symmetric ops:
type Grant = OpEnvelope & { type: 'grant'; iss: string; // issuer pubkey (must == opId.author) aud: string | "*"; // audience pubkey, or "*" for defaults cmd: string[]; // capabilities being granted};
type Revoke = OpEnvelope & { type: 'revoke'; iss: string; // issuer pubkey (must == opId.author) target: string | "*"; // pubkey whose caps to remove, or "*" cmd: string[]; // capabilities being revoked};Both target either a real pubkey OR the magic value "*" (wildcard,
used for setting defaults that apply to all pubkeys without explicit
per-user state).
Bootstrap special case: when permsLww is empty AND the op is a
grant AND iss == aud == sessionMeta.ownerPubkey AND cmd includes
/, the op is accepted without an authority lookup. This is the
inductive base case of the validation walk (see §1.0.1).
Notes:
opId.author === issfor both ops. The issuer is always the signer.- For
grant: the special “self-grant of /” form is the bootstrap. All other grants requireeffectiveCaps(iss) ⊇ cmd ∪ {/grant}. - For
revoke: requireseffectiveCaps(iss) ⊇ cmd ∪ {/revoke}PLUS the strict-superset rule (§5.3) for non-self revokes. cmdis an array of capability paths (§5).- Uniqueness via
hlc+ sequentialopId.seqper author.
3.3 The opId field
Section titled “3.3 The opId field”All op types use sequential { author, seq } opIds:
type OpId = { author: string; // signer pubkey (matches Ed25519 verifying key) seq: number; // 1-based, monotonic per author per session};No content-addressed { author, hash } form exists anywhere in v0.4.
Powerline-style sessions where multiple tabs share a link keypair require careful seq management at the application layer (the link’s signer must coordinate seq across tabs), or each tab generates a fresh per-tab keypair (typical). v0.4 doesn’t prescribe a policy.
3.4 Canonical serialization for signing
Section titled “3.4 Canonical serialization for signing”(Unchanged from v0.1.) The signed payload is the op object excluding
the signature field, serialized as canonical JSON per RFC 8785,
UTF-8 encoded.
§4 Signing, verification, and trust gating
Section titled “§4 Signing, verification, and trust gating”4.1 Sign on emit
Section titled “4.1 Sign on emit”(Unchanged from v0.1.) Every op emitter MUST sign before broadcast or local apply.
4.2 Verify on receive — v0.4 LWW grants
Section titled “4.2 Verify on receive — v0.4 LWW grants”The receive-side verification flow:
1. Structural validation: required fields present, formats correct.2. Extract signature, canonicalize op without signature, verify Ed25519 signature against op.opId.author. Reject if invalid.3. Bootstrap check (only for the first grant op of a session): if permsLww is empty AND op.type === 'grant' AND op.iss == op.aud == sessionMeta.ownerPubkey AND '/' ∈ op.cmd: accept (bootstrap path); skip to step 6.4. Authority lookup: issuerCaps = effectiveCaps(op.opId.author) — see §4.3. If issuerCaps is empty (and not a bootstrap): buffer on iss — see §4.4.5. Op-type-specific authority check: - grant: '/grant' ∈ issuerCaps AND cmd ⊆ issuerCaps. - revoke: '/revoke' ∈ issuerCaps AND cmd ⊆ issuerCaps AND (iss == target OR strict-superset rule §5.3). - chess-data op: required cmd for op.type ∈ issuerCaps. Reject if check fails.6. Apply: - grant: for each c in cmd, set (aud, c) register to GRANTED@hlc. - revoke: for each c in cmd, set (target, c) register to REVOKED@hlc. - chess-data op: standard op-vocabulary apply rules.7. If a grant/revoke advanced effectiveCaps(target) from empty: drain any ops buffered on iss:<target>.4.3 Computing effective capabilities
Section titled “4.3 Computing effective capabilities”For any pubkey K:
effectiveCaps(K): caps = ∅ for each cmd in CMD_LATTICE: userReg = permsLww.get((K, cmd)) if userReg.state == GRANTED: caps.add(cmd); continue if userReg.state == REVOKED: continue # explicitly excluded wildReg = permsLww.get(("*", cmd)) if wildReg.state == GRANTED: caps.add(cmd) return capsNo recursion. No chain walk. No HLC parameter — permsLww holds the
current value of each register, with LWW resolution already applied
during op-apply.
Per-user takes priority over wildcard. Once a per-user register exists (GRANTED or REVOKED), the wildcard doesn’t reach that user for that cmd. This is what allows demoting individuals below the default posture.
The capability lattice (§5) is consulted when checking hasCapability:
holding / implies holding /play etc.
4.4 Causal-delivery buffering
Section titled “4.4 Causal-delivery buffering”An op whose iss has empty effectiveCaps (and isn’t a valid
bootstrap op) is buffered on key iss:<iss> rather than rejected.
When a later op grants caps to that pubkey, the buffer drains.
This handles the race where a downstream op (e.g., Bob’s first move) arrives before the grant op that authorized Bob.
Note: the buffer drains only when the iss’s caps go from EMPTY to NON-EMPTY. Subsequent grants/revokes that modify already-active caps don’t trigger drain (the relevant ops were never buffered).
4.5 Causal-stability of capability checks
Section titled “4.5 Causal-stability of capability checks”Permission ops and mutation ops are not commutative: a perm op can retroactively affect the validity of a mutation op at a higher HLC. Without care, two peers receiving the same set of ops in different orders can reach different derived states. To preserve strong eventual consistency (SEC), v0.4 specifies:
Rule 1 (HLC-indexed cap checks): every mutation op M is checked
against effectiveCapsAt(M.iss, M.hlc), not the current cap state.
The check uses the perm history (§1.0.2) to determine the issuer’s
caps at M’s HLC, not at apply time.
Rule 2 (perm wins the seam): when both a perm op and a mutation
op have the same HLC, the perm op’s effect is in force for the
mutation’s cap check. The lookupEntry helper (§1.0.2) uses ≤ H
inclusive — a perm entry at exactly H counts.
Rule 3 (tentative apply + finalization): for responsiveness,
mutation ops apply optimistically to derived state when they arrive.
They’re tagged tentative: true until the stability watermark (§9b)
advances past their HLC. When watermark advances:
for each mutation op M with hlc ≤ watermark, tentative: capsAtM = effectiveCapsAt(M.iss, M.hlc) if requiredCmd(M.type) ∈ capsAtM: M.tentative = false # finalize as valid else: tombstone(M) # remove from derived stateA tombstoned mutation is removed from derived state (e.g., state.nodes
loses the addNode’s effect). The op envelope may persist in the op log
for audit, but contributes nothing to the visible state.
Rule 4 (perm op invalidating earlier mutation): when a perm op P
arrives with P.hlc < some-tentative-mutation-M.hlc, M’s cap-state at
M.hlc may have changed. Re-check M’s validity using the updated
effectiveCapsAt(M.iss, M.hlc). If M is now invalid, tombstone
immediately (don’t wait for watermark — we already know the answer
won’t change again unless another perm op arrives below M.hlc, which
gets handled the same way).
UX implication
Section titled “UX implication”In the common case (no concurrent perm op affecting a mutation), mutation ops apply instantly and the finalization step is a no-op. The user never sees a difference.
In the edge case (perm + mutation concurrent on the same pubkey), the mutation may briefly appear and then disappear (“vanishing move”) when retroactively invalidated. Rare in practice: requires the owner (or another revoke-capable peer) to issue a revoke during active editing by the affected user.
Why this is CRDT-correct
Section titled “Why this is CRDT-correct”The validity of any mutation op M is a pure function of:
- M itself,
- The perm history with hlc ≤ M.hlc,
- The deterministic
effectiveCapsAtlookup.
Receive order doesn’t enter the function. Once any two peers have seen the same set of ops, they compute the same validity for every mutation. The “tentative” state during reception convergence is behavior, not protocol divergence — by the time stability watermark crosses any given HLC, all peers have settled on the same answer.
4.6 Pluggable signer / verifier
Section titled “4.6 Pluggable signer / verifier”(Unchanged from v0.1.) OpenFileTarget accepts signer and verifier
implementations at construction. Default uses Web Crypto.
§5 The capability lattice
Section titled “§5 The capability lattice”5.1 The path vocabulary
Section titled “5.1 The path vocabulary”Capabilities are command paths inspired by UCAN’s cmd field. The
v0.4 vocabulary:
graph TD ROOT["/ — full owner authority"] ROOT --> MOD["/moderate<br/>edit · delete others' content"] MOD --> PLAY["/play<br/>author moves · delete own subtree"] PLAY --> COM["/comment<br/>comments · NAGs · drawables"] COM --> VIEW["/view<br/>read-only"] ROOT --> GRANT["/grant<br/>issue grant ops"] ROOT --> REVOKE["/revoke<br/>issue revoke ops"] classDef orth fill:#3a2c18,stroke:#d0a060,stroke-dasharray:5 4; class GRANT,REVOKE orth;The hierarchy under / is a capability inclusion lattice, not
strict UCAN path-prefix semantics. Holding /play implicitly includes
/comment and /view. Holding /moderate includes /play. Holding
/ includes everything (including /grant and /revoke).
/grant and /revoke are orthogonal — neither implied by content
caps nor implies them. They can be granted independently.
Rename note: v0.3’s /grant is renamed to /grant in v0.4 for
naming coherence with the grant op. v0.4 also adds /revoke as the
symmetric capability for the revoke op.
5.2 The inclusion table
Section titled “5.2 The inclusion table”/ ⊇ /moderate, /play, /comment, /view, /grant, /revoke/moderate ⊇ /play, /comment, /view/play ⊇ /comment, /view/comment ⊇ /view/view ⊇ (nothing)/grant ⊇ (nothing)/revoke ⊇ (nothing)(Reading: left includes right. Holding the left grants the right.)
Apps and the apply pipeline reference this table:
is_grant(A, x): ∃ a ∈ A such that a == x OR a ⊇ xis_subset(B, A): ∀ b ∈ B, is_grant(A, b)5.3 Attenuation and strict-superset
Section titled “5.3 Attenuation and strict-superset”Attenuation (applies to both grant and revoke):
∀ c ∈ op.cmd: is_grant(effectiveCaps(op.iss), c)Every command being acted on must already be held by the issuer. You can’t grant what you don’t have; you can’t revoke what you don’t have.
Strict-superset rule (applies to non-self revoke only):
For revoke with op.iss != op.target:
strictlyDominates(effectiveCaps(op.iss), effectiveCaps(op.target))
where strictlyDominates(A, B) is true iff: B ⊊ A # B is a proper subset of A (i.e., A includes everything in B AND has at least one cmd B lacks)This means:
- A user with
/moderatecan revoke caps from anyone with strictly fewer caps (e.g., a/playuser, a/commentuser). - Two users both with
/playcannot revoke each other (equal caps). - A
/playuser cannot revoke from a/moderateuser (would be revoking up). - Self-revoke is always allowed (the rule doesn’t apply to self).
This prevents adversarial peer-revoke (two co-owners can’t fight over ownership) while enabling top-down moderation (the owner can demote anyone below them).
5.4 Why a custom lattice instead of strict path-prefix
Section titled “5.4 Why a custom lattice instead of strict path-prefix”UCAN treats cmd as a single URI path with subpath delegation
(“granting /crud grants /crud/read and /crud/write”). We could
have done the same: /all/moderate/play/comment/view. But:
- The role names users actually want (Viewer, Commenter, Player, Moderator, Owner) read naturally as the cmds themselves.
- The orthogonal
/grantand/revokecapabilities don’t fit any path-prefix ordering — they must be separate paths either way.
A small static table is simpler than path-string semantics and easier to reason about. We’re not interoperating with UCAN deployments; the adaptation is sound.
§6 Per-op capability requirements
Section titled “§6 Per-op capability requirements”6.1 The mapping table
Section titled “6.1 The mapping table”Each chess-data op type has a required cmd:
| Op | Required cmd | Notes |
|---|---|---|
addNode | /play | Authoring a move |
setMainChild | /play | Promoting your variation |
setNodeDeleted (own subtree) | /play | Deleting what you authored |
setNodeDeleted (others’) | /moderate | Removing others’ contributions |
setOpDeleted (own op) | /play | Tombstoning own content |
setOpDeleted (others’ op) | /moderate | Moderation |
addSegment (comment) | /comment | Adding comment text |
addNagContribution | /comment | Adding annotations |
setDrawable | /comment | Adding arrows/squares |
setScalar | /comment | Other annotations |
grant | /grant + cmd ⊆ own | Granting caps to a pubkey (or ”*” for defaults) |
revoke | /revoke + cmd ⊆ own + strict-superset (§5.3) | Removing caps from a pubkey |
6.2 Own-vs-others determination
Section titled “6.2 Own-vs-others determination”For ops like setNodeDeleted and setOpDeleted, “own” means: the
signer is the author of the target node/op being deleted.
- Own subtree: walk from
setNodeDeleted.nodeHashto find any node in the subtree authored by someone other than the signer. If none, it’s an “own” delete; require/play. If any other author, require/moderate. - Own op:
setOpDeleted.targetOpId.author === signer's pubkey→ “own”; require/play. Else require/moderate.
This makes the per-author authoring vs moderator distinction enforceable at the protocol level.
6.3 Reads are not gated by capability
Section titled “6.3 Reads are not gated by capability”A peer receiving any op via the transport applies it without capability check — receiving and applying ops is part of state convergence and doesn’t require authorization. Authorization gates the emit path only.
For “true” read-access control (preventing certain pubkeys from
receiving state at all), see the transport spec: the relay can
enforce a server-side filter using the same capability lattice
(e.g., a viewer-banned pubkey list). That’s a transport-layer
concern, not an op-layer one.
§7 The Powerline pattern (anonymous-with-link)
Section titled “§7 The Powerline pattern (anonymous-with-link)”7.1 The problem
Section titled “7.1 The problem”A study owner wants to mint a “share link” that:
- Anyone who has the link can join with full role R
- Works when the owner is offline
- Doesn’t require coordination between concurrent joiners
- Doesn’t share long-lived signing keys across processes
The bearer-token UX (Google Docs share links) but expressed cryptographically.
7.2 The pattern
Section titled “7.2 The pattern”When the owner creates a share link, they:
- Generate a fresh link keypair
(linkPk, linkSk). - Emit a
grantop:{ type: 'grant', iss: ownerPk, aud: linkPk, cmd: <R> } - Encode the link URL:
#s=<sessionId>&lk_pub=<linkPk>&lk_priv=<linkSk>&cmd=<R>
When a peer opens the link:
- Parse the URL →
(sessionId, linkPk, linkSk, R). - Generate a fresh tab keypair
(K_tab, K_tab_sk). - Connect to the session via transport (which gives them op-log
replay including the owner’s grant to the link;
effectiveCaps(linkPk)becomesR). - Emit a
grantop signed bylinkSk:where{ type: 'grant', iss: linkPk, aud: K_tab, cmd: <R'> }R' ⊆ R(typicallyR' == R). - From here on, sign chess ops with
K_tab_sk.
Existing peers verify the new tab’s ops via the trust gate (§4.2):
K_tab is in the LWW grants frame with capability R', so the tab’s
signed chess ops are accepted. Single map lookup, no chain walk.
7.3 Why this resolves the v0.1 collision problem
Section titled “7.3 Why this resolves the v0.1 collision problem”In v0.1, multiple tabs sharing a signing key collided on the per-author
sequential seq. The Powerline pattern doesn’t share the link key for
chess ops; each tab has its own K_tab for those. The link key is used
only to sign per-tab grants, which use the link’s own seq counter.
If tabs need to share the link’s seq counter (because they emit through
the same linkSk), the application coordinates seq across tabs, or
simpler: each tab generates its own link-equivalent keypair. Most apps
choose the per-tab approach.
7.4 Bearer-token semantics
Section titled “7.4 Bearer-token semantics”Possession of the link’s private key confers the ability to sub-delegate up to R. This is the bearer-token model: holding the URL IS the credential. Sharing the URL effectively shares the authority.
Apps that need stricter semantics (link can only be used once, link
tied to a specific user) layer that on top — they could, for example,
add a server-side check that gates which aud a link can grant to.
The protocol doesn’t enforce these; it provides the cryptographic
foundation on which they can be built.
7.5 Worked example
Section titled “7.5 Worked example”Owner Alice creates a study, mints two links:
A: "viewer link" → cmd: ["/view", "/grant"]B: "editor link" → cmd: ["/play", "/grant"]Two tabs open via link A (Carol, Dave). One tab opens via link B (Bob).
State after all joins (the grants frame):
permsLww (showing GRANTED entries; UNSPECIFIED entries omitted): ownerPk → / GRANTED (via bootstrap self-grant) linkA_pk → /view, /grant (granted by ownerPk) linkB_pk → /play, /grant (granted by ownerPk) carol_pk → /view, /grant (granted by linkA_pk) dave_pk → /view, /grant (granted by linkA_pk) bob_pk → /play, /grant (granted by linkB_pk)Bob emits an addNode op signed by bob_pk. Verification:
effectiveCaps(bob_pk)={/play, /grant}. Required:/play. ✓- Op accepted.
Carol emits the same addNode. Verification:
effectiveCaps(carol_pk)={/view, /grant}. Required:/play. ✗- Op rejected (
insufficient-capability).
Each tab signed with its own per-tab key; concurrent joins didn’t collide (different pubkeys → different sequential opIds); the bearer-token UX is preserved.
§8 Per-link generation lifecycle
Section titled “§8 Per-link generation lifecycle”8.1 Library API for link generation
Section titled “8.1 Library API for link generation”A reference helper for apps building share-link UX:
type LinkSpec = { cmd: string[]; // capability bundle nbf?: number; // optional not-before (reserved v0.2) exp?: number; // optional expires (reserved v0.2)};
createShareLink(target: OpenFileTarget, spec: LinkSpec): Promise<{ url: string; // full URL with hash delegationHash: string; // content hash of the Powerline delegation op}>;Implementation: generate keypair, emit delegate op (signed by the
caller — they must have /grant and the requested cmd ⊆ their own),
encode URL, return both.
8.2 Joiner-side bootstrap
Section titled “8.2 Joiner-side bootstrap”joinFromLink(url: string): Promise<{ target: OpenFileTarget; game: TabiaGame;}>;Implementation:
- Parse URL hash.
- Generate K_tab keypair.
- Construct transport, connect.
- Wait for log replay (transport spec v0.2 §4).
- Construct OpenFileTarget without ownerSigner; verify the root delegation is present in the replayed log.
- Emit per-tab delegation signed by linkSk.
- Wrap with
createGameFromTarget(Tabia). - Return both.
8.3 Trust-state propagation under concurrency
Section titled “8.3 Trust-state propagation under concurrency”Two scenarios worth naming:
Concurrent joiners. Tab 1 and Tab 2 open link A simultaneously.
Each generates its own K_tab, signs its own per-tab delegation under
the link key’s identity. If both tabs use the same linkSk, they
need to coordinate opId.seq (the link key’s per-author counter);
the simplest approach is for each tab to generate a fresh keypair
that’s granted by the link, rather than sharing linkSk directly.
Both delegations apply at every peer; both K_tab pubkeys appear in
grants. State converges.
Late joiner. Tab 3 opens the link an hour after Tab 1 closed.
Tab 3 receives the link’s delegation via op-log replay (or via
manifest hydration if the relay snapshotted). Tab 3 signs its own
per-tab delegation. Tab 3’s chess ops verify against grants.get(K_tab).
8.4 What happens when the link itself is revoked
Section titled “8.4 What happens when the link itself is revoked”Deferred. When revoke ships, revoking a link delegation removes
its aud from grants. Sub-delegations the link issued become
orphaned — the iss lookup at apply time will fail, so future ops
referencing those tab pubkeys will be rejected. Historical ops
already applied are unaffected.
8.5 URL scheme is app territory, not protocol
Section titled “8.5 URL scheme is app territory, not protocol”The protocol returns { publicKey, privateKey } from createShareLink
and accepts { linkSigner, linkDelegationHash } to acceptInviteFromLink.
How those credentials get from the inviter to the joiner — URL
encoding, server-side link tables, QR codes, email payloads, SMS — is
an application-layer concern. The protocol is URL-scheme-agnostic.
Two common patterns:
- URL-bearer (decentralized). Credentials are encoded directly in the URL fragment (the dev harness pattern). Self-contained; works without a server holding link state. URLs are long (~130 chars for a 32-byte private key + pubkey via base64url).
- Server-side link table (Lichess / Notion / Linear style). The
app server mints a short token, stores
(token → credentials), and the URL carries just the token. URLs are short (~30 chars). The app server becomes a (limited) trust authority: it can mint extra peers under the link’s identity, and link availability depends on the server.
Apps choose. The protocol’s responsibility is the cryptographic primitives that compose with either pattern.
Things that fall to the app along with URL scheme:
- Link rotation (mint a new link, revoke the old).
- Link expiration UX (
expin the envelope is reserved for protocol v0.3; meanwhile apps can wrap with their own TTL). - Link permission display (“This link grants edit access”).
- Custom token formats (UUID, ULID, short ID, hash of credentials).
- Link redemption logging / analytics.
The OpenFile relay holds the session op log but does NOT hold a link table. Adding one is a deliberate app-server expansion, not a relay feature.
§9 Sync mode integration
Section titled “§9 Sync mode integration”(Mostly unchanged from v0.1 in spirit; rephrased for the new model.)
9.1 Spectator mode
Section titled “9.1 Spectator mode”A peer with /view only. Cannot emit chess ops; locally generates
ephemeral keypair for code-path uniformity. The transport may filter
which ops they receive (e.g., excluding private back-channels) — that’s
a transport-layer policy decision.
9.2 Follow-leader mode
Section titled “9.2 Follow-leader mode”A peer with /view plus the app-level affordance to follow a
specific peer’s cursor. The cursor sharing is a UX feature
(transport-spec presence channel), not a capability-layer concern.
9.3 Collaborative mode
Section titled “9.3 Collaborative mode”A peer with /play or above. Full participation in the chess tree.
9.4 Promotion / demotion
Section titled “9.4 Promotion / demotion”Promoting a peer from spectator to collaborator: the upgrading peer
emits a grant op for the target, conferring /play (or above).
They must have /grant and the new cmd ⊆ their own.
Demoting: emit a revoke op. Subject to attenuation + strict-superset
(§5.3) for non-self revokes.
§9b Stability watermark and heartbeats
Section titled “§9b Stability watermark and heartbeats”v0.4 introduces a stability watermark mechanism for continuous, automatic GC of the live op log. This is distinct from the manifest watermark (an owner-attested archival anchor; see op-vocab §10.9).
9b.1 Two watermarks, two purposes
Section titled “9b.1 Two watermarks, two purposes”| Watermark | Purpose | Set by |
|---|---|---|
| Stability watermark | Continuous op-log GC | Derived from observed peer progress |
| Manifest watermark | Owner-attested archival anchor | Explicit exportManifest call |
They can coincide but typically don’t. The stability watermark moves continuously (every op + heartbeat); the manifest watermark moves discretely when the owner publishes.
9b.2 Definition
Section titled “9b.2 Definition”Each peer tracks per-peer maximum observed HLC:
state.peerMaxHlc: Map<pubkey, HLC>Updated on every received op or heartbeat from that pubkey. Stability watermark is:
stabilityWatermark = min(peerMaxHlc[p] for p in transport.connectedPeers())When a peer’s peerMaxHlc advances or a peer joins/leaves, the
watermark is recomputed and the op log is GC’d accordingly.
9b.3 The silent-viewer problem and heartbeats
Section titled “9b.3 The silent-viewer problem and heartbeats”Pure observation-based watermark advancement has a gap: a peer who is
connected but never emits (a spectator) doesn’t update other peers’
view of their HLC progress. Their peerMaxHlc entry stays stale,
pinning the watermark.
Solution: heartbeats. Each connected peer periodically (default: every 10 seconds) sends a lightweight transport-level message:
type Heartbeat = { type: 'hlc-heartbeat'; peer: pubkey; hlc: { physical, logical };};Heartbeats are not ops — they don’t go in the op log, don’t carry
signatures, don’t affect state.permsLww. They’re a transport-level
signal that updates peerMaxHlc[peer], allowing the stability
watermark to advance through silent viewers.
Peers SHOULD throttle heartbeats: only send if no op has been emitted in the last heartbeat interval. Active emitters effectively don’t send heartbeats (their ops update peerMaxHlc directly).
9b.4 GC semantics
Section titled “9b.4 GC semantics”Once the stability watermark advances past HLC W, all ops with
HLC ≤ W can be dropped from the live op log. Their derived state is
preserved in permsLww, nodes, segments, etc.
Late joiners or returning peers catch up via snapshot transfer (manifest + PGN, or transport-level state dump) rather than op replay. A peer whose own HLC is below the current stability watermark cannot contribute new ops at that HLC — their ops would be rejected as below-watermark. They must accept a current snapshot and resume.
9b.5 What this means for DX
Section titled “9b.5 What this means for DX”App developers see none of this. The library:
- Tracks
peerMaxHlcfrom received ops + heartbeats. - Computes
stabilityWatermarkautomatically. - GC’s the op log continuously.
- Emits heartbeats from the local peer on a timer.
Transport implementations expose:
- Per-peer connection events (joined/left).
- Heartbeat delivery (as ordinary transport messages, distinct from ops).
exportManifest and advanceWatermark remain opt-in APIs for
archival, but op log size stays bounded automatically without app
intervention.
§10 Sample integrations
Section titled “§10 Sample integrations”10.1 Solo viewer (~30 lines, unchanged from v0.1)
Section titled “10.1 Solo viewer (~30 lines, unchanged from v0.1)”const target = createOpenFileTarget({ sessionMeta: { startFen: START_FEN }, // No ownerSigner: this is a read-only client. verifier: createWebCryptoVerifier(),});const game = createGameFromTarget(target);// ... wire up board, slice subscribers, etc.10.2 Anonymous collab with link (~80 lines)
Section titled “10.2 Anonymous collab with link (~80 lines)”// CREATOR side:const owner = await generateKeypair();const target = await createOpenFileTarget({ sessionMeta: { rootKeys: [owner.pubKey], startFen: START_FEN }, ownerSigner: owner.signer, verifier: createWebCryptoVerifier(),});// target's first op is the root delegation, auto-emitted.
const { url } = await createShareLink(target, { cmd: ['/play', '/grant'],});console.log('Share this URL:', url);
// JOINER side (different tab/device):const { target, game } = await joinFromLink(window.location.href);// Now game.playMove('Nf3') just works — its ops are signed by K_tab// and verify against link → owner → root.10.3 Account-bound collab (~150 lines)
Section titled “10.3 Account-bound collab (~150 lines)”App layer maps account → persistent keypair (stored encrypted). The
account-holder’s pubkey is added to rootKeys (or granted by a root key
via delegate). Per-link mechanism unchanged.
§11 What’s deferred
Section titled “§11 What’s deferred”11.1 Revocation
Section titled “11.1 Revocation”A revoke op type that removes a pubkey from grants (or restricts
its cmd set):
type Revoke = OpEnvelope & { type: 'revoke'; target: string; // pubkey to revoke cmd?: string[]; // optional: revoke specific caps; default: all};Verification rules:
- Issuer must have
/grant. - Issuer must have previously granted target (or be a rootKey).
Effect:
- Removes target from
grants, or removes the specified cmd set. - Future ops from the revoked pubkey are rejected.
- Historical ops already in the oplog remain valid.
Open sub-problem: revocation propagation under network partition. A peer who hasn’t yet received a revocation may continue to accept ops from the revoked pubkey. Best-effort: divergence resolves once the revocation propagates.
11.2 Time-bound enforcement
Section titled “11.2 Time-bound enforcement”nbf and exp fields exist in the envelope but are not enforced.
Future work:
- Apply-time check that op’s HLC is within issuer’s
[nbf, exp]. - Clock skew tolerance (recommended ±60s).
11.3 Policy language
Section titled “11.3 Policy language”UCAN-style jq-inspired predicates on op arguments. Useful for things like “editor on this subtree only,” “commenter on positions matching this opening.” Deferred until a concrete use case arrives.
11.4 DID URI scheme
Section titled “11.4 DID URI scheme”did:key:z6Mk... over our base64 pubkeys. Standard interop format
for the W3C DID ecosystem. Trivial to add later.
11.5 Identity unification
Section titled “11.5 Identity unification”“WestPortalian and JohnBoyer are the same person.” Cryptographically unifying handles across sessions. Probably never for chess.
11.6 Capability discovery
Section titled “11.6 Capability discovery”A peer querying “what can pubkey K do here?” — useful for UX
affordances (greying out buttons the user can’t use). Achievable as a
read-side derivation (derive(K, currentHlc) exposed on OpenFileTarget);
deferred until UX demand emerges.
§12 What this spec does NOT cover
Section titled “§12 What this spec does NOT cover”- Slice machinery / event subscription. Lives on Game (Tabia lifecycle spec).
- Cursor sharing / presence. Transport-spec feature.
- Engine / analysis state. Game-level concern; engines don’t have protocol identity.
- Persistence formats. op-vocab §10.
- Wire framing / TLS / reconnect. Transport spec.
- Recovery from compromised root keys. “Start a new session.”
§13 Open questions for v0.2 review
Section titled “§13 Open questions for v0.2 review”-
Should
subbe required (always == sessionId) instead of nullable? The Powerlinesub: nullsemantics is elegant but our single-subject sessions don’t strictly need it. Could simplify to “sub is always the sessionId.” Punted on for now to keep the door open for v2 sub-session scoping (“editor on this study only, viewer on others”). -
Should
cmdbe a single path with implicit subpath grants, or the explicit array we have? Explicit array is clearer for role bundles (["/play", "/grant"]); subpath would force users to express orthogonal/grantas a top-level path. Settled on arrays. -
What’s the maximum delegation chain depth? UCAN doesn’t bound it but recommends caching. For chess sessions, depths >5 are probably abuse — should we hard-cap? Tentatively: 10 hops, reject anything deeper.
-
/commentvs separate/annotate? v0.2 conflates text comments, NAGs, and shape annotations under/comment. If apps want to grant “may annotate but may not write text comments,” we’d split. Deferred.
§14 What’s next
Section titled “§14 What’s next”After v0.2 is locked:
- Op-vocab v0.6 — adds
delegateop type to the vocab; removesaddTrustedKey/removeTrustedKey; adds the cmd-requirement table to each op type’s section. - Transport v0.2 — adds relay op-log buffer (for offline-host bootstrap), session-seed protocol, LOG_REPLAY message type.
- Implementation — replace
state.trustedKeys/state.trustOpsByHlcwithstate.delegations(content-addressed Map<hash, Delegate>). Add chain-walking verifier. Replace boolean trust check with per-op capability gate. - Multi-tab dev harness — landing page + per-tab client, all
routed through Tabia’s
createGameFromTarget, using the Powerline pattern for joins.
Appendix: relationship to UCAN
Section titled “Appendix: relationship to UCAN”OpenFile’s v0.2 trust model is structurally inspired by UCAN but adapted in three ways:
-
CRDT integration. UCAN is request-response; OpenFile is convergent state. Delegations live in the op log alongside chess data; their effects are derived through the same apply pipeline.
-
Custom capability lattice. UCAN uses path-prefix semantics for capability inclusion. We use a small static inclusion table tailored to chess roles. The trade-off is described in §5.4.
-
No invocation envelope. UCAN distinguishes “I have authority” (delegation) from “I’m using it” (invocation). For OpenFile, every signed op is implicitly an invocation; we fold the two together. Each chess-data op carries its authority indirectly through its signer’s chain.
Where UCAN matures and starts to interop with chess ecosystems, we can adopt their wire format directly — the model is close enough that the path is open.