Skip to content

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.

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:

  1. Per-register history in permsLww (above the stability watermark; collapsed below). Memory cost is small — typically 0–3 entries per register at any time.
  2. HLC-indexed cap lookup via effectiveCapsAt(K, H) — every mutation op M is checked against caps at M.hlc, not current state.
  3. 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.

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.

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.

  • 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.

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.

(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.


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.


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 to aboveWatermark.
  • revoke(aud, cmd) — appends a REVOKED entry to aboveWatermark.

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.

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 ≤ H

lastEntryWithHlcAtMost 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).

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.

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:

  1. Verify Ed25519 signature against iss.
  2. If permsLww is empty: bootstrap path (see §1.0.1).
  3. Else: issuer must hold /grant AND cmd ⊆ effectiveCaps(iss).
  4. 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:

  1. Verify Ed25519 signature against iss.
  2. Issuer must hold /revoke AND cmd ⊆ effectiveCaps(iss) (you can only revoke caps you currently hold).
  3. 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.
  4. For each c in cmd: set (target, c) register to REVOKED@op.hlc.

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.

(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.

(Unchanged from v0.1.) Each op signed individually by its emitter’s identity key. Session-key indirection and batch signing are deferred.

(Unchanged from v0.1.) op.opId.author is the base64-encoded Ed25519 public key (44 chars). The “author IS the key” — no fingerprint indirection.

(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 explicit grant ops. Revocation is symmetric: revoke ops set registers to REVOKED via the same LWW resolution.

Three rules:

  1. 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.
  2. Authority lookup is a single map read. No chain walk; no recursion. effectiveCaps resolves via per-user register, with fallback to wildcard, both LWW.
  3. 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.

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.


  • Wire format for signatures and public keys (Ed25519, base64).
  • Canonical serialization of ops for signing.
  • The grant and revoke op 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.
  • 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.
  • 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”
  • revoke op (§11.1) — invalidates a previously-issued delegation. v0.3.
  • Time-bound enforcement (§11.2)nbf / exp fields 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.
  • 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.

Every op carries a mandatory signature field — base64-encoded Ed25519 signature over the op’s canonical JSON serialization (excluding signature itself).

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 === iss for both ops. The issuer is always the signer.
  • For grant: the special “self-grant of /” form is the bootstrap. All other grants require effectiveCaps(iss) ⊇ cmd ∪ {/grant}.
  • For revoke: requires effectiveCaps(iss) ⊇ cmd ∪ {/revoke} PLUS the strict-superset rule (§5.3) for non-self revokes.
  • cmd is an array of capability paths (§5).
  • Uniqueness via hlc + sequential opId.seq per author.

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.

(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”

(Unchanged from v0.1.) Every op emitter MUST sign before broadcast or local apply.

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>.

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 caps

No 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.

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).

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 state

A 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).

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.

The validity of any mutation op M is a pure function of:

  • M itself,
  • The perm history with hlc ≤ M.hlc,
  • The deterministic effectiveCapsAt lookup.

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.

(Unchanged from v0.1.) OpenFileTarget accepts signer and verifier implementations at construction. Default uses Web Crypto.


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.

/ ⊇ /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 ⊇ x
is_subset(B, A): ∀ b ∈ B, is_grant(A, b)

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 /moderate can revoke caps from anyone with strictly fewer caps (e.g., a /play user, a /comment user).
  • Two users both with /play cannot revoke each other (equal caps).
  • A /play user cannot revoke from a /moderate user (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 /grant and /revoke capabilities 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.


Each chess-data op type has a required cmd:

OpRequired cmdNotes
addNode/playAuthoring a move
setMainChild/playPromoting your variation
setNodeDeleted (own subtree)/playDeleting what you authored
setNodeDeleted (others’)/moderateRemoving others’ contributions
setOpDeleted (own op)/playTombstoning own content
setOpDeleted (others’ op)/moderateModeration
addSegment (comment)/commentAdding comment text
addNagContribution/commentAdding annotations
setDrawable/commentAdding arrows/squares
setScalar/commentOther annotations
grant/grant + cmd ⊆ ownGranting caps to a pubkey (or ”*” for defaults)
revoke/revoke + cmd ⊆ own + strict-superset (§5.3)Removing caps from a pubkey

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.nodeHash to 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.

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.


Section titled “§7 The Powerline pattern (anonymous-with-link)”

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.

When the owner creates a share link, they:

  1. Generate a fresh link keypair (linkPk, linkSk).
  2. Emit a grant op:
    { type: 'grant', iss: ownerPk, aud: linkPk, cmd: <R> }
  3. Encode the link URL:
    #s=<sessionId>&lk_pub=<linkPk>&lk_priv=<linkSk>&cmd=<R>

When a peer opens the link:

  1. Parse the URL → (sessionId, linkPk, linkSk, R).
  2. Generate a fresh tab keypair (K_tab, K_tab_sk).
  3. Connect to the session via transport (which gives them op-log replay including the owner’s grant to the link; effectiveCaps(linkPk) becomes R).
  4. Emit a grant op signed by linkSk:
    { type: 'grant', iss: linkPk, aud: K_tab, cmd: <R'> }
    where R' ⊆ R (typically R' == R).
  5. 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.

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.

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.


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.

joinFromLink(url: string): Promise<{
target: OpenFileTarget;
game: TabiaGame;
}>;

Implementation:

  1. Parse URL hash.
  2. Generate K_tab keypair.
  3. Construct transport, connect.
  4. Wait for log replay (transport spec v0.2 §4).
  5. Construct OpenFileTarget without ownerSigner; verify the root delegation is present in the replayed log.
  6. Emit per-tab delegation signed by linkSk.
  7. Wrap with createGameFromTarget (Tabia).
  8. 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).

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 (exp in 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.


(Mostly unchanged from v0.1 in spirit; rephrased for the new model.)

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.

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.

A peer with /play or above. Full participation in the chess tree.

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.


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).

WatermarkPurposeSet by
Stability watermarkContinuous op-log GCDerived from observed peer progress
Manifest watermarkOwner-attested archival anchorExplicit 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.

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).

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.

App developers see none of this. The library:

  • Tracks peerMaxHlc from received ops + heartbeats.
  • Computes stabilityWatermark automatically.
  • 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.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.
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.

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.


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.

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).

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.

did:key:z6Mk... over our base64 pubkeys. Standard interop format for the W3C DID ecosystem. Trivial to add later.

“WestPortalian and JohnBoyer are the same person.” Cryptographically unifying handles across sessions. Probably never for chess.

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.


  • 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.”

  1. Should sub be required (always == sessionId) instead of nullable? The Powerline sub: null semantics 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”).

  2. Should cmd be 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 /grant as a top-level path. Settled on arrays.

  3. 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.

  4. /comment vs 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.


After v0.2 is locked:

  • Op-vocab v0.6 — adds delegate op type to the vocab; removes addTrustedKey / 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.trustOpsByHlc with state.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.

OpenFile’s v0.2 trust model is structurally inspired by UCAN but adapted in three ways:

  1. 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.

  2. 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.

  3. 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.