README.md

Corvus fixtures — acceptance suite #

Cross-language acceptance suite for any client implementing the Corvus op-CRDT applier. A conformant implementation takes the input in edit/<name>.json, applies the operation stream, and produces the output in materialized/<name>.json.

Layout #

fixtures/
  edit/<name>.json          — input: array of edit records
  materialized/<name>.json  — expected output: one page.corvus.materialized
  incremental/<name>.json   — self-contained "load + apply" tests (initial state → edits → final state)

Every edit/<name>.json has a matching materialized/<name>.json. The applier test hydrates the edit stream and compares against the materialized fixture under deep structural equality.

incremental/<name>.json exercises the same applier in a different mode: it takes a pre-existing materialized view and applies new edits on top of it, asserting the resulting view. This proves the materialized record is self-sufficient — the applier doesn't need the original op history, only the materialized snapshot plus new ops.

Input format — edit/<name>.json #

A JSON array of edit records as they would arrive from a PDS or firehose:

[
  {
    "uri": "at://did:plc:alice/page.corvus.edit/<rkey>",
    "cid": "bafyrei...",
    "record": {
      "$type": "page.corvus.edit",
      "createdAt": "2026-05-12T10:00:00.000Z",
      "project": "at://did:plc:alice/page.corvus.project/<rkey>",
      "parents": [
        /* page.corvus.core#opId — predecessor ops the author was aware of */
      ],
      "operations": [ /* op union — at least one op */ ]
    }
  }
]

Record shape and op union are defined in lexicons/page/corvus/edit.json. Each op carries its own stable id (a page.corvus.core#opId {did, tid, clientId}); op identity is baked into the op, not derived from the containing edit's rkey or the op's array position, so ops are portable across edit records.

parents lists opId entries the author was aware of when authoring this edit; the applier topologically orders edits so any edit appears after every edit containing one of its parent ops.

Per-op status #

Each op carries an optional suggestion boolean. When true, the op defaults to pending status; its effects are projected via the pending-update mechanism (see below). When absent or false, the op defaults to applied.

This suite covers only the suggestion-driven default. Review-record overrides are out of scope: resolving them is the review resolver's job and lives above the applier.

Output format — materialized/<name>.json #

A single page.corvus.materialized value:

{
  "$type": "page.corvus.materialized",
  "project": "at://did:plc:alice/page.corvus.project/<rkey>",
  "blocks": [
    {
      "id":           /* page.corvus.core#opId — block id (createBlock op id) */,
      "parent":       /* page.corvus.core#register — omitted on the project root */,
      "afterSibling": /* page.corvus.core#register — omitted on first-child */,
      "blockType":    "page.corvus.block#<variant>",
      "properties": [
        { "name": "<field>", "value": /* register | counter | orSet | seq */ }
      ]
    }
  ]
}

Key shape decisions:

  • No per-variant block schema in the output. Every field on a block variant materializes as an entry in the generic properties array. The applier doesn't know what variants exist — it just stores every write keyed by (blockId, register) and projects them through the uniform register | counter | orSet | seq shape. blockType is carried through verbatim from the minting createBlock.block.$type.
  • Tree shape lives on the envelope. parent and afterSibling are registers on the materialized block (omitted when never written). The applier never builds a child list — render layers walk parent back-pointers and order siblings via the afterSibling chain.
  • opId / atomId in object form. Both core#opId ({did, tid, clientId}) and core#atomId ({did, tid, clientId, atomIdx}) appear as JSON objects throughout. Implementations may keep a stringified shorthand internally but the canonical fixture form is structural.
  • Properties array order doesn't have a normative rule — comparison tools should compare by name. Fixtures use a deterministic order (field declaration order on the variant) for readability.

Required applier behaviors #

A conformant applier must:

Op identity and dedup #

Op identity is the id field on each op (a core#opId). If two edit records carry an op with the same opId (e.g. an edit and a sync-mirrored copy of it), the applier dedupes and applies the op exactly once.

Atom ids #

Every addressable thing is an atomId: <opTid>/<opDid>/<clientId>.<atomIdx> in stringified form, or {did, tid, clientId, atomIdx} as an object.

  • createBlock mints a single block atom at atomIdx=0 of its own opId. The block's id is that opId.
  • rgaInsert minting an N-character text run places atoms at consecutive atomIdx 0..N-1. Atom 0 is anchored by after; atom i (i>0) by atom i-1 (RGA-Split).
  • The atom id space is globally unique by construction. Cross-author references name the target's atom id verbatim with no rewriting.

Op semantics #

Op Behavior
createBlock Mints a block at the op's opId. block carries $type, id, and every required (constant, non-editableAs) field the variant declares — for most variants this is parent; for some it's more (counter.initialValue, dateTime.value, mention.did, link.url, image.image, embeddedDocument.document, comment.author, cell.columnId, facet.startAtom/endAtom). All non-required fields (every `editableAs: register
deleteBlock Tombstones a block by blockId. The block disappears from materialized.blocks but remains addressable for ops referencing it. Idempotent.
rgaInsert Insert into the RGA sequence named by seq. Anchor by after atom id; omit after to anchor at start-of-sequence. For seq: "text" / "title", value is a string run; one atom per character.
rgaDelete Tombstone a contiguous range of atoms minted by op opId from firstAtomIdx through lastAtomIdx inclusive. Omit either bound to extend to the start/end of the run. Idempotent. Tombstoned atoms remain valid after: anchors.
registerSet LWW write on the named scalar register. Newer opId wins; concurrent writes tiebreak by opId descending (stringified <tid>/<did>/<clientId>).
counterIncrement PN-counter: sum of signed deltas. Idempotent per opId.
setAdd / setRemove OR-set semantics. A value is present iff max(addOpId for value) > max(removeOpId for value). Duplicate adds dedupe on rendering. A removed value can be re-added by a later op.

createBlock is always applied regardless of suggestion — an unplaced block is dangling, so gating existence has no visible effect. All other op kinds honor the suggestion flag.

Causal order #

Edits are topologically sorted by parents: every edit appears after every edit containing one of its parent ops. Among causally-incomparable edits:

  • Primary key: createdAt ascending.
  • Tiebreak: rkey of uri ascending.

External parents (referenced but not in the input) are treated as already-applied — they don't add an in-set dependency.

Pending materialization #

Every op may be pending (carries suggestion: true). Pending ops are not folded into the committed value; instead, each container carries the pending ops verbatim on a pendingOps field, and consumers compute their own projections by replaying the ops.

  • #register.pendingOps — [#registerSet]
  • #counter.pendingOps — [#counterIncrement]
  • #orSet.pendingOps — [#setAdd | #setRemove]
  • #seq.pendingOps — [#rgaInsert | #rgaDelete]

Each entry is the source op as authored (with its id, target fields, suggestion: true, etc.). Entries sorted by op id ascending for determinism. Field omitted when no pending ops target the container.

A pending deleteBlock does not remove the block from output; the block remains visible but applier implementations may surface the pending delete via a separate channel (not part of this suite — open question).

Fixture manifest #

Basics #

Fixture Proves
project-root.json Single createBlock for a #document project root. No parent / afterSibling.
child-with-parent.json Two createBlocks — the child carries parent inline in block (parent is a required constant on #header).
delete-block.json deleteBlock removes a block from output; sibling blocks unaffected.

Sequences #

Fixture Proves
text-insert.json rgaInsert on text at start-of-sequence mints a run of N atoms; materialized seq has one #run.
text-delete-mid.json rgaDelete of a mid-range tombstones a contiguous slice; materialized seq splits into run / tombstone / run.
concurrent-insert-tiebreak.json Two concurrent rgaInserts at start-of-sequence (both omit after); higher op id (Bob's tid > Alice's) renders first in the materialized seq. RGA descending-sibling tiebreak.

Registers #

Fixture Proves
register-set.json registerSet on level produces a #register property with the written value.
register-set-lww-concurrent.json Two concurrent registerSets (different authors, no causal order) tiebreak by stringified opId descending — Bob's later-tid wins.

Counters #

Fixture Proves
counter-increment.json #counter with initialValue: 0 (required constant); three counterIncrement deltas (+2, +3, -1) sum to 4. Materialized counter has initialValue as a #const property alongside the editable count counter.
counter-concurrent.json Two concurrent counterIncrements (+5, +3) commute to sum 8 on top of initialValue: 0.

OR-sets #

Fixture Proves
or-set-add-remove.json setAdd("alpha"), setAdd("beta"), setRemove("alpha") — final set is ["beta"] (later remove wins for "alpha").
or-set-readd-wins.json setAdd("alpha"), setRemove("alpha"), setAdd("alpha") — final set is ["alpha"] (later add wins).

Tree #

Fixture Proves
tree-reorder-siblings.json Sibling reorder via registerSet on afterSibling. Three children A,B,C reordered to A,C,B by two writes (C.afterSibling=A, B.afterSibling=C). parent is constant on each block (set at create), so reparenting is out of scope — only #document and #database have editable parent.

Stream ordering #

Fixture Proves
causal-parents-order.json Edits delivered out of order in the input array are topologically sorted via parents. Bob's edit (delivered first) references Alice's edit (delivered second) as parent; final order is Alice → Bob. Also exercises cross-author atom-id anchoring (Bob anchors after: on Alice's text atom).
op-dedup-cross-edit.json The same op (same opId) appears in two distinct edit records. The applier dedupes and applies the op exactly once.

Pending state #

Fixture Proves
pending-text-insert.json Committed rgaInsert "Hello" + pending rgaInsert " world". The text seq's committed value has one run; pendingOps carries the pending rgaInsert verbatim.
pending-register.json Committed registerSet level=2 + pending registerSet level=4. Committed value 2; pendingOps carries the pending registerSet verbatim.
pending-counter.json Committed +5 + pending +3. Committed value 5; pendingOps carries the pending counterIncrement verbatim.
pending-or-set.json Committed setAdd("alpha") + pending setAdd("beta"). Committed value ["alpha"]; pendingOps carries the pending setAdd verbatim.

Incremental fixtures — incremental/<name>.json #

A page.corvus.materialized record carries enough state for the applier to continue processing edits without seeing the original op history. lastOpIds is the dedup frontier — any incoming op whose id is in lastOpIds is treated as already-applied.

Format #

{
  "initial":  { /* page.corvus.materialized — starting state */ },
  "edits":    [ /* array of edit records — same shape as edit/<name>.json */ ],
  "expected": { /* page.corvus.materialized — state after applying edits */ }
}

Harness logic:

  1. Load initial into the applier as if it had just produced this state.
  2. Feed edits through the same applier path used by the edit/ → materialized/ tests.
  3. Compare the result deeply against expected.

What an applier must do to consume initial #

  • Treat each entry in initial.blocks as committed state for that block (by id): parent register, blockType, every entry in properties (including #const constants).
  • Treat initial.lastOpIds as the set of applied op ids — dedup incoming ops against it.
  • New ops with id in lastOpIds are no-ops. New ops with id not in lastOpIds apply normally; their id joins lastOpIds in the output.
  • For LWW comparisons (e.g. concurrent registerSets), the committed value in initial is the winner so far. A new op with a higher opId overrides it; a new op with a lower opId loses (since the initial's winner was already the max-so-far).

Manifest #

Fixture Proves
incremental-text-insert.json Start with a prose block holding "Hello"; apply a new rgaInsert anchored on the existing text's last atom; final seq has two runs. Exercises continued RGA anchoring across a load/apply boundary.
incremental-counter.json Start with count: 5; apply counterIncrement +3; final count: 8. Confirms the applier seeds the PN-counter from the committed value, not from a history of deltas.
incremental-set-add.json Start with values: ["alpha"]; apply setAdd("beta"); final values ["alpha", "beta"]. Exercises OR-set continuation — the initial "alpha" was added by some prior op now folded into lastOpIds, and the new "beta" op has a higher opId by construction.
incremental-new-block.json Start with just a document root; apply createBlock + registerSet level=2; final state has a new header child. New blocks correctly bind to existing parents from the initial state.
incremental-op-dedup.json New edit's operations array contains an op whose id is already in initial.lastOpIds (a re-delivery) and another that's new. Applier skips the dup, applies the fresh op exactly once.

Comparison semantics #

Deep structural equality.

  • JSON object key order does not matter.
  • blocks array order does not carry semantic meaning, but fixtures use creation order; implementations may sort by id (lex) before comparison.
  • properties array entries should be compared by name; the fixture order is deterministic but not normative.
  • pendingOps array order is normative — sort by op id ascending (tid first, then did, then clientId).
  • seq.value array order is normative — RGA produces a deterministic sequence.

Out of scope for this suite #

  • Sync agent. Cross-repo mirroring of edits. Not exercised here.
  • Block-variant-specific semantics. The applier treats every variant generically — blockType is recorded, properties are stored and projected uniformly. No block-type-aware behavior is required.