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
propertiesarray. The applier doesn't know what variants exist — it just stores every write keyed by(blockId, register)and projects them through the uniformregister | counter | orSet | seqshape.blockTypeis carried through verbatim from the mintingcreateBlock.block.$type. - Tree shape lives on the envelope.
parentandafterSiblingare 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 theafterSiblingchain. - opId / atomId in object form. Both
core#opId({did, tid, clientId}) andcore#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.
createBlockmints a single block atom atatomIdx=0of its own opId. The block'sidis that opId.rgaInsertminting an N-character text run places atoms at consecutiveatomIdx0..N-1. Atom 0 is anchored byafter; 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:
createdAtascending. - Tiebreak: rkey of
uriascending.
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:
- Load
initialinto the applier as if it had just produced this state. - Feed
editsthrough the same applier path used by theedit/→materialized/tests. - Compare the result deeply against
expected.
What an applier must do to consume initial #
- Treat each entry in
initial.blocksas committed state for that block (byid):parentregister,blockType, every entry inproperties(including#constconstants). - Treat
initial.lastOpIdsas the set of applied op ids — dedup incoming ops against it. - New ops with
idinlastOpIdsare no-ops. New ops withidnot inlastOpIdsapply normally; theiridjoinslastOpIdsin the output. - For LWW comparisons (e.g. concurrent
registerSets), the committed value ininitialis 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.
blocksarray order does not carry semantic meaning, but fixtures use creation order; implementations may sort byid(lex) before comparison.propertiesarray entries should be compared byname; the fixture order is deterministic but not normative.pendingOpsarray order is normative — sort by op id ascending (tidfirst, thendid, thenclientId).seq.valuearray 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 —
blockTypeis recorded, properties are stored and projected uniformly. No block-type-aware behavior is required.