diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index e1076342..5da7e31b 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -1188,7 +1188,7 @@ An actor's **effective roles** on a resource = token roles (global) ∪ roles as Distinct mechanisms, not interchangeable — conflating them is a security hole: - **App admin** — the schema-registry authority (the app owner / CI). Lives *above* every document: registers schemas, migrations, and the static `@auth` for an `app_id`, and is a **superuser** that may act on every document in the app (bypasses the policy, decision-flow step 0). A credential class (the registration key), **not** a role and **not** an owner; never appears in `@auth` grants. -- **Owner** — a **dynamic, recursive, path-scoped capability** held by an actor over a room or a path within it. An owner has full access to its subtree *and* meta-authority (grant / revoke) over it. The document creator auto-owns the root path `/`; multiple owners per path are allowed. Owners live as **doc-level ACL state** (the CRDT tier), self-organized at runtime — never declared in the schema. The **creator binding itself is not CRDT state** — it is the first authenticated writer, which the document cannot name — so it is server-side **room metadata, replicated with the room** and durable beside it, alongside the room's governing `{app, version}` binding and its op-version high-water: the three facts a replica needs to decide how the state it holds is read, none of which the ops or the snapshot bytes carry. A replica that holds a room must therefore hold the root its redactions resolve against: a node holding only the ACL tuples holds the grants without the authority they are decided under, and evaluates every one of them as inert. The root rides the room's replication frames, and — since it is established by facts the room's ops do not always accompany, a write the room's dedup swallowed whole and a replica whose durable record was lost being the two — on a **metadata-only frame of its own** where no batch is going that way. That frame carries no stream position and so **creates no room**: a node that does not hold the room is converged by the ops or snapshot catch-up, each of which carries the root itself, and a frame that created one would leave an empty replica the node then advertises as servable. A room that has reached no sequence is the one case with neither — nothing to converge a replica with, and no tuples for a root to decide — so it is carried nowhere. Each composes against what the replica already holds rather than replacing it — the root set-once, the high-water as a max, the binding on the incumbent-app rule — because a frame is an assertion by a peer, not an instruction. +- **Owner** — a **dynamic, recursive, path-scoped capability** held by an actor over a room or a path within it. An owner has full access to its subtree *and* meta-authority (grant / revoke) over it. The document creator auto-owns the root path `/`; multiple owners per path are allowed. Owners live as **doc-level ACL state** (the CRDT tier), self-organized at runtime — never declared in the schema. The **creator binding itself is not CRDT state** — it is the first authenticated actor to present the room a write, which the document cannot name — so it is server-side **room metadata, replicated with the room** and durable beside it. **A no-op frame reserves nothing**, and it takes two conditions to say so, because the reservation has two shapes. *The room must have reached a sequence* — it owns `/` and every deny is decided under it, and a room at sequence zero has had nothing put in it to be the authority over — which stops a frame carrying nothing from *minting* a room and owning it. That one is stated at the single seam every actor-asserted root composes through (a client write, a peer's ops frame, and the metadata-only frame below), so a root asserted by a peer is judged like one a write establishes, and stated as a sequence rather than as retention because that is what the seam can check: this node's own count of what it took. *And the batch must have presented an op* — which stops the same frame from taking `/` over a room that already holds content and has no root, a real state left by an anonymous establishing commit or by a replica whose metadata write was lost. That one is a statement about what a client offered, so it lives at the client write seam alone: the replication callers adopt a root established elsewhere, and the metadata-only frame is by construction the one with no batch beneath it. It deliberately does **not** reach the two seams where a root arrives *with a state* — a snapshot install and the durable record read back off the store — because there the sequence came in beside the root rather than being counted here, and no honest catch-up names zero at all: gating those refuses only a sender that would name a nonzero floor instead, while leaving a state full of content **rootless** and dropping a root the node itself established once a transfer has left the room at zero. Both are the inert-deny hole reached from the other side, which is worse than the reservation the rule refuses. The line between the two is what the batch *presented*, not what landed: a write the room's dedup swallows whole still roots a room that already holds ops, because presenting the room's content is something only a replica holding it can do, while an empty frame presents nothing and costs its sender nothing. Refusing the resend as well would buy attributability — the fresh-op route leaves an op in the log under the taker's id and claims that identity, the deduped route leaves no record of who took `/` — and cost an attacker one op; what it would cost the design is the only shape by which a root is established with no batch to replicate it on. It is durable alongside the room's governing `{app, version}` binding and its op-version high-water: the three facts a replica needs to decide how the state it holds is read, none of which the ops or the snapshot bytes carry. A replica that holds a room must therefore hold the root its redactions resolve against: a node holding only the ACL tuples holds the grants without the authority they are decided under, and evaluates every one of them as inert. The root rides the room's replication frames, and — since it is established by facts the room's ops do not always accompany, a write the room's dedup swallowed whole and a replica whose durable record was lost being the two — on a **metadata-only frame of its own** where no batch is going that way. That frame carries no stream position and so **creates no room**: a node that does not hold the room is converged by the ops or snapshot catch-up, each of which carries the root itself, and a frame that created one would leave an empty replica the node then advertises as servable. A room that has reached no sequence is carried nowhere: the frame creates no room, so a node holding none is unreachable by it, and one that holds a zero-sequence room establishes no root there by the rule above. That room is not necessarily empty — a state install takes its floor from its frame and its content from its bytes — so a replica of one that lost its root has a repair nothing currently carries. Each composes against what the replica already holds rather than replacing it — the root set-once, the high-water as a max, the binding on the incumbent-app rule — because a frame is an assertion by a peer, not an instruction. - **Role** — a static, schema-declared name (`viewer` / `editor`) whose powers are the schema `@auth` grants. Membership is two-source: a **token** claim (global, for app-wide roles) or a **doc-level ACL** assignment to an actor or group (per-doc — the usual case). The schema defines what a role *can do*; who *has* it is a token claim or a per-doc grant, never the schema. ## Ownership (Dynamic Capability Model) diff --git a/DECISIONS.md b/DECISIONS.md index 5d2dc7f2..0c15c07b 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -7,6 +7,20 @@ Log of design changes to [ARCHITECTURE.md](ARCHITECTURE.md) that implementation The entries below (2026-07-02) are a backfill: design changes made during the v0.1→v0.2 build that predate this log, recovered from the sessions and commit history. +## 2026-08-09 · C99 no-op root reservation · a no-op frame reserves nothing, and saying so takes two conditions — one about the room, one about the batch — which is why they sit at different seams + +**Changed:** ARCHITECTURE §Owner gains both conditions on the creator binding, the seam each is stated at, and why they cannot be the same seam. No wire change, no state change — the establishment rule narrowed. + +**The defect, reproduced first.** `ensure_creator` fired on any `Ok` from `Hub::ingest`, and an empty batch answers `Ok(vec![])` — while the ingest's `entry(room).or_insert_with(Room::new)` **materialises the room** on the way through. So the first authenticated actor to send a no-op `Ops` frame at an unestablished room became its creator: the doc-ACL authority root that owns `/`, over a room at sequence zero, having authored no byte the room retains. Reproduced at the wire, and with the privilege it actually steals rather than the field: the creator is exempt from every deny in the room, so a squatter that holds `/` reads straight through the room author's `Deny(Read)` aimed at it. The same reservation reached the replication path twice — an empty `Replicate`, which ingests identically and creates the room on the follower, and a `ReplicateMeta` at a room held and empty. + +**Two conditions, because the reservation has two shapes, and a falsification pass found the second still open after the first shipped.** The room-level condition — no root over a room at sequence zero — lives in `may_stand_as_root` at `Hub::ensure_creator`, which a client's write, a peer's `Replicate` and the metadata-only `ReplicateMeta` all compose through, so the replication path is judged by it rather than left with the hole. That is the trap the filing named, and it forces the *shape*: "only a batch that landed" (C23's rule one tier down) is not statable at `ReplicateMeta`, which has no batch beneath it by construction (C55). But being a rule about the room, it says nothing once the room has reached a sequence — and **a room holding content with no root is a real state**, left by an anonymous establishing commit or by a replica whose best-effort metadata write was lost, which are C55's own two routes. There a frame carrying nothing satisfied the room rule and took `/` over content its sender had no part in; measured at the wire, with the squatter then reading through the room author's deny, and permanent, since set-once locks the content's authors out afterwards. The condition that refuses it is a statement about what the batch *presented*, so it sits at the client write seam alone: the replication callers adopt a root established elsewhere, and a peer asserting one it did not earn is refused by the replica-set gate, not by this one. + +**The line is what the batch presented, not what landed.** A resend the room dedups whole still roots a room that already holds ops — presenting the room's content is something only a replica holding it can do, so an attacker must obtain it, where an empty frame presents nothing and costs its sender nothing. C55 left this open for C99 to rule and built `ReplicateMeta` to replicate the root either way. Refusing the resend as well would buy **attributability** — the fresh-op route leaves an op under the taker's `ClientId` and claims that identity, the deduped route leaves the room's state with no record of who took `/` — at the cost of C55's route 1, whose only shape is a root established by a write the dedup swallowed whole, against an attacker who pays one op to route around it. C23 ruled the other way one tier down, where the act *is* the taking of an identity another replica's historic ops wrote; a room's root is the room's, not another actor's, so the same act does not carry the same theft. + +**Where the rule stops was measured, not chosen.** A first cut also applied the room condition at the two seams where a root arrives *with a state* — a snapshot install, and the record read back off the store — for uniformity. Falsification measured both as worse than the defect: a state full of content whose frame names floor zero came up **rootless**, leaving every doc-ACL tuple in it with no authority to be decided under, and a room a state transfer had left at sequence zero lost, on its next reload, a root the node itself established over content it still holds. Both are C29's inert-deny hole reached from the other side, and both were found by falsification rather than by the suite — the first cut's tests all passed. The distinction that survives is *whose number the sequence is*: this node's own count at the write seam, and a number that came in beside the root at the other two, where no honest catch-up even names zero (a `Snapshot` is served only below a room's floor). That last point cuts both ways and is recorded rather than glossed: the only producer of content-at-floor-zero is a buggy or hostile sender, so refusing its root is not obviously a loss — what is not circular is the *reload*, which has no frame at all and drops a root over content the node still holds. C141 owns the floor-zero state itself. + +**A rooted room at sequence zero is not the empty thing it reads as.** A state install takes its floor from its frame and its content from its bytes, so such a room can hold a whole document, tuples included. C55's `catch_up_room_frame` guard against dialing a root for a zero-sequence room stands — the frame creates no room, and a node that holds one refuses the root on arrival by the same rule — but its stated reasons ("a follower holds no such room", "no ACL tuples for a root to decide") were both false and are corrected; the residue is filed as C141. The C55 test that built its zero-sequence fixture out of this defect now builds it from the install. + ## 2026-08-09 · C47 minority `count` rewrite · a bucket whose members disagree **spends its key** rather than holding — reversing what C3 decided a disagreement means **Changed:** ARCHITECTURE §Opt-In: Atomic, and the change is an **inversion rather than an extension** — a reader six months out should see that C3's "a disagreement means hold" was overturned deliberately. The sentence "a bucket whose members disagree names no group and is never complete" stood alone as the bound on a rewritten `count`; it now continues into the rule that such a bucket resolves its key at the point it disagrees. Three tests that pinned the hold inverted with it, named below; the C3 entry in this file and the C3 and C21 entries on the board gained forward pointers so the superseded rule is not read as current, and C21's entry here is corrected where this unit overtakes it. The paragraph listing what the C21 record deliberately does not take loses the minority rewrite from its residue and narrows "does not release a bucket that merely *looks* unreachable" to the count a bucket is short of, which is the case that argument was ever about. No wire or state format moves: the key lands in `resolved_tx`, which `STATE_VERSION` 13 already carries. diff --git a/KANBAN.md b/KANBAN.md index c39e41ac..b981a1f3 100644 --- a/KANBAN.md +++ b/KANBAN.md @@ -30,6 +30,8 @@ Element + Map are one coupled unit (Map slots hold Elements; Element forwards li _Derived from code + git; a convenience view, not the source of truth._ +**C99 — an `Ops` frame carrying no ops bootstrapped a room's doc-ACL authority root (crates/server) — DONE (#403).** Filed by C23 (#389), found by its review; pre-dated it. `ensure_creator` fired on any `Ok` from `Hub::ingest` and an empty batch answers `Ok(vec![])` — while the ingest's `entry(room).or_insert_with(Room::new)` **materialises the room** on the way through. So the first authenticated actor to send a no-op `Ops` frame at an unestablished room became its creator: the doc-ACL authority root that owns `/`, over a room at sequence zero, having authored no byte the room retains. Reserve-by-no-op, the shape C23 closed one tier down for the replica-identity claim, and the sharper of the two — a claim holds one id space, a creator holds the document. **Reproduced before a line of fix, and again after the first fix shipped**, each time as a failing test first: at the wire; with the privilege it actually steals rather than the field (the creator is exempt from every deny, so a squatter holding `/` reads straight through the room author's `Deny(Read)` aimed at it); an empty `Replicate`, which ingests identically and creates the room on the follower; and a `ReplicateMeta` at a room held and empty. **Two conditions close it, and they sit at different seams because they are different statements.** *The room must have reached a sequence* — `may_stand_as_root`, in `Hub::ensure_creator`, which a client's write, a peer's `Replicate` and the metadata-only `ReplicateMeta` all compose through, so the replication path is judged by it rather than left with the hole. That was the trap the filing named, and it forces the shape: "only a batch that landed" (C23's rule) is **not statable at `ReplicateMeta`**, which has no batch beneath it by construction (C55). *And the batch must have presented an op* — at the client write seam alone. **The second was found by falsification after the first shipped**, and it is the half that bites hardest: being a rule about the room, the first says nothing once the room has reached a sequence, and a room holding content with **no root** is a real state — C55's own two routes leave one (an anonymous establishing commit; a replica whose best-effort metadata write was lost). There a frame carrying nothing took `/` over content its sender had no part in, measured at the wire with the squatter then reading through the author's deny, and permanent, since set-once locks the content's authors out afterwards. The replication callers state no such condition and must not: they adopt a root established elsewhere, and a peer asserting one it did not earn is refused by the replica-set gate. **The line is what the batch presented, not what landed:** a resend the room dedups whole still roots a room that already holds ops, because presenting the room's content is something only a replica holding it can do, while an empty frame presents nothing. Refusing the resend too would buy attributability (the fresh-op route leaves an op under the taker's `ClientId` and claims that identity) at the cost of C55's route 1, against an attacker who pays one op to route around it; C23 ruled the other way one tier down, where the act *is* the taking of another replica's identity. **Where the room rule stops was measured, not chosen.** A first cut also applied it at the snapshot install and the store load, for uniformity; falsification measured both as worse than the defect — content installed at floor zero came up **rootless**, every tuple in it inert, and a room a state transfer left at sequence zero lost a legitimately-established root on reload over content it still holds. Both are C29's inert-deny hole from the other side, and neither was caught by the suite: the first cut's tests all passed. Both are now pinned as tests. **Mutation sweep, statement deletion over both conditions, the call site, and the two seams left alone: 0 survivors.** **Four falsification passes, each in its own worktree.** Two independently measured the reload strip that subtracted the extra seams; one found the consequence test *root-independent* (a deny beats a schema-tier grant from any grantor, so only the creator's **exemption** discriminates) and both of C55's dial-guard reasons false; the fourth, run against the final sha, found the empty frame still took a rootless room's authority and that the reload fixture's own comment was false about its fixture (it installed an empty state, so the room held nothing). Spec: `crates/server/tests/creator_establishment.rs` (14). ARCHITECTURE §Owner; DECISIONS 2026-08-09. Filed **C140** (a no-op batch still mints a hub room that nothing prunes, so reserve-by-no-op survives at the *room* level) and **C141** (a zero-sequence room can hold a whole document, tuples included, and a replica of one that lost its root has a repair neither the dial nor the frame can carry). → *Server / Security*. + **C14 — a redacted op delta left the same re-mint hole, and no frame could carry the ids it withheld (crates/server + crates/core) — DONE (#398).** C9 (#357) closed the *snapshot* seam by keeping the recipient's own ids in a projected snapshot's frontier; an uncompacted room serves an op delta, and the per-op read filter is authorship-blind, so it withheld the recipient's own ops on paths it may no longer read. Reproduced on C9's own fixture before anything was written: a reader with write at `/` and read on `/a` writes into `/b` then `/a`, restarts, `next_seq` reports **0**, and the next write mints straight onto its own withheld id and dedups away at ingest, silently. **The state encoding was the carrier for the snapshot; an `Ops` frame has none, so one is added.** `Message::Frontier { channel, seqs, reach }` (tag 54, server→client) leads a redacted catch-up delta; `Document::note_published` folds what it names into a `reserved` set the mint searches. **C9 explicitly refused a wire frontier and every one of its four reasons is seam-specific**, which is why the answer flips here: it supplies no number to the *sequence* counter (one named sequence is one held id and one search step — a frame naming `u64::MAX` leaves a fresh replica minting `0`), and the `reach` half, which is a wire number reaching the id-space floor, buys nothing an op carrying this replica's id does not already buy under the same ceiling; it carries the codebase's own decode bound; "a scalar cannot express a hole" is exactly why this is a **set**; and "`seen` rides `encode_state`" was the case *for* `seen` on the snapshot seam, while the delta frame has no state to ride — and what it installs rides `encode_state` anyway (`STATE_VERSION` 15). **Sequences, not `OpId`s**, so no other replica's id space is representable and the "only the recipient's own" rule is a property of the frame rather than a check on it — and the frame carries none of the targets or content that made the filing's other candidate shape (stop scrubbing the reader's own ops) unshippable. **A falsification pass broke the first cut twice and both are the substance.** *(1) A mint reads two records and the first cut carried one.* The lamport position comes from `mint_floor`, and the snapshot seam keeps it (`scrub_high_water_to`); the first frame carried only sequences, so every id derived from a stamp **alone** — an ACL tuple's, a ranged element's, an XML sequence child's — re-derived across a redacted delta. Measured: a restarted reader's next mark took the byte-identical `ElementId` of the mark its delta withheld, and the room dropped it at ingest. The frame now carries `reach`, taken from the withheld ops themselves so a branch stream reports its own and the figure is the tighter of the two available, clamped exactly as `record_stamp` clamps a folded stamp. *(2) Naming a sequence must not be refusing its op.* The first cut folded the run into the dedup set following C9, whose justification — "the ops sit below the snapshot's sequence, so no later delivery meets them" — a delta does not have: the client's cursor advances by the *delivered* batch length (C117), so a reader whose run ends in the withheld subtree resumes from below its own last ops and a widened grant re-serves exactly them. Measured: both dropped as replays while another author's write into the same subtree folded normally. The ids now go to `reserved`, cleared by `apply` when the op arrives. **The frontier leads the delta** because a provider opens the socket to app traffic *on a frame* and an app can author between two. Both the Python and JS providers completed the initial sync on the wrong frame (JS's gate read `lastSeenSeq(channel) !== undefined`, satisfied from the moment the channel is held, so *any* frame completed it); both now match the catch-up reply by tag and channel — an allowlist, Go's shape, chosen over excluding the new tag because a denylist is re-broken by the next server-directed frame. **The live fan-out is deliberately given nothing**, on the invariant that a replica applies its own op at authoring time — which the falsification pass broke as stated (two connections declaring one `ClientId` under one actor), so it is restated as a property of one replica per identity and filed as **C130**. Spec `crates/server/tests/delta_frontier.rs` (12), `crates/core/tests/op_seq_high_water.rs` (+20), `crates/core/tests/state_project_seen.rs` (+2), `crates/core/tests/protocol.rs` (+4), `sdks/python/tests/test_provider_lifecycle.py` (+1), `sdks/js/test/provider.lifecycle.test.ts` (+1). **A seventeen-branch statement-deletion sweep against the shipped code left four mutants green and none was redundant code** — each was a hole in the suite, all four in the new reservation set (`apply`'s clear, `adopt_as`'s clear, the decoder's reserved-over-applied check, both projections' clear), and the last exposed a second vacuous test since `adopt_as` masks the projection rule from anything read back through `decode_state_as`. Pinned, and the sweep now leaves no survivor. ARCHITECTURE §Wire-Level Redaction + DECISIONS 2026-08-09. Three residues filed: **C130** (the live seam's exemption rests on one replica per identity), **C131** (the read half of the identity boundary C23 closed for writes), **C132** (the frame buys durably-retained ids at 8 wire bytes each, bounded only by the frame cap). → *Server / Fan-out*. **C53 — a log-shared branch materializes truncated once `main` compacts, and says nothing (crates/server) — DONE (#399).** Filed by review during C27 (#375) as reasoned-not-reproduced; **reproduced first**, five shapes, each a failing test before a line of the fix. `catch_up_branch`'s shared-base slice clamped its low end up to the compaction floor (`last_seen_seq.max(r.base_seq) - r.base_seq`), so every record at or below the floor was dropped from the served base and the answer was still `Catchup::Ops`. Measured on `main`: a fork at 4 in a room compacted to 2 folds, for a fresh subscriber, to `one=None two=None three=3 four=4 tail=9`; a floor raised to the fork point folds to the divergent tail over nothing, with the same fork serving its whole base one statement earlier as the control; `base_seq >= fork_point` reaches the same place by the other route, an empty retained slice rather than a clipped one; `install_snapshot` reaches it with no compaction configured anywhere, which is the ordinary below-floor follower state-transfer; and **at the wire**, on a room holding no doc-ACL tuples so that C60's `no_tree` refusal is skipped, a `Subscribe` at `last_seen_seq = 0` is answered with one `Ops` frame carrying the branch's tail op and nothing else. That last is the reachability claim the filing rested on, and it holds. The read was the only consequence left: C60 (#386) made a clipped shared base yield no redaction tree and C51 (#393) split `materialize_branch` four ways, so a publish of such a stream already froze nothing and a diff already answered `UnreadableBranch` — the catch-up was the seam still answering `Some`, to the one caller that acts on it by *becoming* the stream. **Ruling: the refusal is bounded by what the subscriber needs, not by what the branch is.** `catch_up_branch` answers `Catchup::Unavailable` when `min(base_end, base_seq) > last_seen_seq`. Asked of a fold from zero that condition is *identical* to `stream_doc`'s own clipped-base check, so the tree a read is redacted against and the stream that read is served cannot describe different branches — a second, differently-shaped notion of "clipped" at the serve seam is exactly the drift C60's lesson warns about. The refusal takes **both** ends of the window, and standing above either is enough to be served — at or above the floor nothing the subscriber still needs was dropped, at or above its own fork point it needs none of the base whatever the floor has since done. Stating one end as *the* bound is wrong in both directions, which a falsification pass measured (a subscriber three records below the floor, served on its fork point) after a first correction over-corrected the other way. That a subscriber past the fork point keeps its tail is `Catchup::Unavailable`'s own standing ruling, which C60 relies on, pinned at the hub and at the wire rather than left to the enum. **The filing's alternative — a compaction floor that will not advance past a live fork point — is rejected, on two counts.** It cannot be complete: `install_room_state` lands an empty log at a raised floor with no `compact` call to guard, so the same wrong `Some` would survive on the path needing no operator action at all. And it converts a correctness bug into an availability one: a branch is long-lived by design (§Per-User Branches), so one forgotten per-user fork would hold a room's log unreclaimable forever, and `set_compaction_threshold` — which compacts inline from `ingest` — would then never reclaim on exactly the rooms that grow. **What this does not fix, stated because three filings share the signature.** The refusal is not a repair: the pre-floor content is still gone and the branch is now unservable at every seam rather than wrong at one. **C88** owns the repair (an owned base materialized for every registered fork whose fork point sits above a floor about to rise, at both compaction and the snapshot install) and its cost — one whole-replica copy per live-log fork, paid where the reclaim was — is the ruling this unit deliberately does not pre-empt. **C104** is untouched and unreachable by this guard: an orphaned base reclassified as a live-log fork produces wrong bytes only at floor **0**, which is precisely where `min(base_end, base_seq) > last_seen_seq` never fires. **The cost of the refusal, measured rather than left to be discovered.** The filing's framing is an aging one — a floor rising past a fork that already existed — and the dominant case is the opposite: a fork taken on an **already-compacted** room is unservable from birth. `fork_branch` clamps to `main`'s head, which is the only point the wire's own fork command names, the floor is already there, and the first fresh subscriber is refused; nothing repairs it, and deleting the name and re-forking reaches the same missing log. Pinned as a test rather than described — on a room whose floor has left zero, which is the condition, a fork on a room still at floor 0 being a pinned control. **And compaction is not the only way a room is born at its floor**: `install_room_state` lands an empty log at a floor equal to the op count the installed state carries, so a **cloned or imported** room has `floor == head` from birth — and `clone_room` is the "duplicate this doc as a template" primitive, which plainly fits the per-user-fork workflow though ARCHITECTURE §Per-User Branches names the room's own `main` rather than a clone. The mechanical fact stands without the linkage and is pinned: a fork on a cloned room is refused from birth on a node that has compacted nothing, and a later `main` write does not rescue it: the fork point stays pinned at the old head, which is the floor. Pinned too. It is the right trade anyway, for two reasons that are not shrugs: it replaces *silent divergence*, and it is the last seam to take a refusal the branch already took everywhere else, so it makes the set consistent rather than inventing a policy. **The cheap repair the reach points at is recorded on C88 rather than taken here** — where a fork point sits at `main`'s head, `main`'s replica *is* the branch's base byte for byte, so a base could be written at the fork seam for one copy at fork time; doing it here would flip the publish and diff answers `branch_state_taxonomy` pins for exactly that fixture, which is re-litigating two merged rulings inside a third. **Two residuals the falsification passes measured, stated rather than closed.** A subscriber's position is what it *claims* — `last_seen_seq` is a client-supplied wire field and nothing verifies it — and the general statement is stronger than the fork-point case: a client asserting **the room's floor** computes the identical `lo = 0` the pre-fix code computed, so wherever the floor sits at or below the fork point it receives byte-identical pre-fix output — measured on four of the spec's five clipped fixtures. It is not universal, and the exception is instructive: where the floor has run *past* the fork point, claiming it also skips past the fork point and so truncates the branch's own tail, leaving the liar with less than the pre-fix answer. The refusal's threshold is discoverable by binary search over subscribes, which recovers `min(fork_point, head, floor)` rather than the floor itself, and only on a room holding no doc-ACL tuples — with tuples, C60's refusal is cursor-independent and every probe returns the same frame. The two flagship cases in the file share a fixture and differ only in that integer. That is the protocol's standing cursor model — the read-your-writes floor is the same field — and not this seam's to close; a server-side per-channel cursor would. It also means a client caught up short by the *pre-fix* server is not healed either. And the guard tests the **floor** end of the window only, so a `main` whose head has *regressed* below a live fork point — reachable through `install_snapshot` at a sequence below one, whose `seq` is peer-supplied — still serves a base short of the fork point with no signal; filed as **C135**, a head regression being a different cause and a different fix. One pre-existing clause is now implied rather than load-bearing and **stays** with a comment saying so — past the refusal the window sits wholly inside the retained log, so `base_end > r.base_seq` and the `.max(r.base_seq)` clamp narrow nothing, but they are what makes the slice arithmetic total on its own inputs rather than on the guard above holding. Spec `crates/server/tests/branch_catchup_floor.rs` (15), with an oracle indifferent to *how* a stream is served — a delta folds from the empty document exactly as a subscriber does, a snapshot decodes — so every case reads "the pre-floor content is there, or the catch-up said no" and stays true under C88's repair as well as under this refusal. Seven reproduce (the straddled floor, the floor at the fork point, the floor past it, the state-transfer route, the wire subscribe, the fork born on an already-compacted room — which also pins that re-forking the name recovers nothing — and the fork born on a *cloned* room, with no compaction anywhere), each reddened by deleting the guard; seven are the controls the refusal must not swallow (a subscriber at the fork point and at the branch head, an uncompacted fork, a floor of 0, a fork at 0, `main`'s compacted catch-up, a snapshot fork, the two boundaries again at the wire, and the one arm where the slice arithmetic still does real work — forked at 4 over a floor of 2, a subscriber at the floor is owed records 3 and 4 and its tail, asserted by identity rather than by count, while one record lower is refused, the `>` boundary itself); the fifteenth measures the identity claim instead of arguing it, across a grid of pre/post-compaction op counts and every fork point from 0 to one past the head, reaching **one** direction and saying so, since `stream_doc` answers the clipped case before it folds but past that check the diff seam *is* this catch-up and an over-refusal propagates into both answers. Mutation-checked against the committed tree: deleting the guard reddens nine, `min(base_end, base_seq)` → `base_seq` alone reddens two, → `base_end` alone reddens three, `min` → `max` reddens five, `>` → `>=` reddens seven; two cases are reddened by no mutation and are named rather than counted as coverage, sitting above the guard's own early returns. ARCHITECTURE §Branches + DECISIONS 2026-08-09. → *Server / Branches*. @@ -466,6 +468,10 @@ scalar / counter / register / element / map (#22–#27), list Fugue (#24), text ## ⏭ Next +**C140 — a batch that lands nothing still mints a room, and nothing ever prunes one (crates/server) — READY, needs a ruling. Filed by C99 (#403), which closed the authority half of the same act.** `Hub::ingest_records` does `rooms.entry(room).or_insert_with(Room::new)` before it knows whether anything will land, so an authenticated `Ops` frame carrying no ops — and a peer's empty `Replicate`, which reaches the same ingest — leaves a `Room` in the hub's map at sequence zero. Nothing removes it: there is no `rooms.remove` or `retain` anywhere in the crate, and the dormant-room sweeps that do exist prune the *presence* map's bindings, not the hub's rooms. So an actor the deployment lets write can enumerate room names and mint one hub room per name, unbounded and attacker-paced, at zero cost to itself and a `Room` (document, dedup set, log, claim map) each to the server. Each is then `holds_room`-servable, which is the harm **C126** names for the epoch-record route — `read_redirect_response` serves a client from the empty replica rather than redirecting — reached here with no durable-write failure and no store at all. C99 closed what such a reservation *takes* (it no longer roots the room); what it *creates* is untouched, so reserve-by-no-op survives at the room level. The ruling wanted is which of the three: refuse an empty batch outright (a behaviour change for the inert edit that C23 and C99 both deliberately kept admissible), create the room only for a batch that lands (cheap, but a `Replicate` and a client write reach the ingest through the same seam and a follower may want the room), or prune zero-sequence rooms on the sweep that already runs (bounded, and it composes with C126's load-path fix). → *Server / Security*. + +**C141 — a room at sequence zero can hold a whole document, and its root is unrepairable there (crates/server) — READY, needs a ruling. Filed by C99 (#403), measured by its falsification pass; the state pre-dates it.** `install_room_state` takes the room's floor from its caller and its content from the bytes, so `install_snapshot(room, state, 0, ..)` lands an arbitrary document — doc-ACL tuples included — at sequence zero, where `holds_room` reports it servable. Two consequences meet there. C55's `catch_up_room_frame` declines to dial a `ReplicateMeta` for a zero-sequence room, and C99 makes `ensure_creator` refuse a root there anyway, so a replica of such a room that lost its best-effort metadata write has a root repair **neither the dial nor the frame can carry** — and a rootless replica short-circuits `reads_whole_document` to `true`, serving every deny in those tuples as inert. Both of the dial guard's original reasons were false and are corrected in place: a follower *does* hold zero-sequence rooms (an empty `Replicate` creates one, which C99's own suite asserts), and such a room *does* have tuples for a root to decide. The reachability is a peer inside the room's replica set naming floor zero, or C126's bare-epoch load — not a client — which is why this is filed rather than fixed here. Neither is `Hub::catch_up` able to serve that content to a subscriber (it yields a `Snapshot` only below `base_seq`, and nothing is below zero), so the document sits unreadable until a compaction lifts the floor; a ruling wants to decide whether a zero-floor install is admissible at all, or whether the floor should be derived from the state's own op count as `import_room` does. → *Server / Replication*. + **C130 — the live fan-out's exemption from the withheld-run frame rests on one replica per identity, and nothing enforces that within one actor (crates/server) — READY, needs a ruling. Filed by C14 (#398), reproduced by its falsification pass.** C14 gives the catch-up delta a frame naming what it withheld of the recipient's own run, and deliberately gives the live fan-out none, on the invariant that a replica applies its own op at authoring time — so the live filter withholding its own echo leaves nothing missing. That is a property of **one replica per identity**, not of the frame, and the fan-out itself never asks who authored an op. Measured: a second connection declaring an existing `ClientId` **under the same actor** plants an op into a subtree the first may not read; the live filter withholds it entirely (`live frames to bob: []`); the first replica's next edit mints onto that sequence and the write is deduped away at ingest, `/a/after = None`. C23 (#389) refuses the *cross-actor* form (`OpsRejected{Forbidden}`, verified in the same measurement), so the residue is exactly the same-actor case — a client bug rather than an attack, but one that silently loses writes and that redaction makes worse than it is on an unredacted room. **The ruling is where it is closed, and there are two places.** Refusing a second live replica under one identity per room is the root fix and belongs beside **C96**'s ruling on when a claim is made; extending the frame to the live seam is the local fix and costs a per-recipient withheld-own computation on every fan-out batch, which the catch-up seam pays once per subscribe. → *Server / Fan-out*. **C131 — a subscriber presenting another replica's `ClientId` is served that replica's withheld run (crates/server) — READY, needs a ruling. Filed by C14 (#398); C9 (#357) named the boundary, C23 (#389) closed the other half of it.** A redaction hands the recipient back its own authorship, which is what keeps a restarted reader minting past ids the room's log binds — on the snapshot seam since C9, on the delta seam since C14. The recipient is the identity declared at Hello, and C23 bound a replica identity to the authenticated actor that first *writes* under it **at the write and deliberately not at the subscribe**, because a claim at the subscribe is a read lockout. So the read half is open: an actor that may read something in a room and presents another's `ClientId` is served that identity's withheld-run count and the id-space position it reaches — how much that replica published into subtrees the *subscriber* cannot read. C9 named this and filed the binding; C23 delivered the write half, and C14 widened what flows through the read half from a compacted room's projected frontier to every uncompacted room's delta. Bounded — one identity's own count and one scalar, no targets, no content, no other author — and it composes with **C96**'s squat rather than adding a new primitive. The ruling is C96's own (when a claim is made, and what an established claim refuses); the narrow answer available without moving that is to serve the withheld run only where the room's record already binds the identity to this actor and none where it is unclaimed, which costs a legitimate first-write-after-restart reader its repair. → *Server / Security*. @@ -474,8 +480,6 @@ scalar / counter / register / element / map (#22–#27), list Fugue (#24), text **C96 — a replica identity's claim is takeable in a room its owner has not yet written to, and the record it lives in grows on the squatter's terms (crates/server) — READY, needs a ruling. Filed by C23 (#389), which measured both.** C23 binds a replica identity to the authenticated actor that first *writes* under it in a room. A `ClientId` rides every op its replica authors, so it is public wherever that replica has written — and first-write is therefore a race the owner only wins where it has already written. In a room it has not, any actor that room already lets write can author one op under the owner's id and take the claim, after which the owner's own writes are refused there. Pinned by `a_claim_taken_before_the_owner_writes_locks_the_owner_out_of_that_room`. It is bounded — the squatter needs write authority on that room, the owner is told (`OpsRejected`/`Forbidden`) rather than disconnected, and the room the owner *has* written in is protected, which is the room its ids are actually spendable in — but it is a denial the pre-C23 tree did not have, and with no rotation (C94) the owner cannot recover from it. **And the squat still lands the original attack, not only the lockout**: the first op under an unclaimed identity is what establishes the claim, so a ceiling stamp spends the owner's mint in that room *and* leaves the identity in the squatter's name — the gate narrows the *second* actor, never the first. The same freedom sizes the record, and cold review measured that it is not bounded by connection count either: `ClientId::for_channel` derives a distinct identity per channel number and nothing caps channels on a connection, so one connection mints claims until the u32 channel space runs out. Each claim rewrites and fsyncs the whole `RoomMeta`, so N claims cost O(N²) bytes written with no eviction — 64 channels on one connection measured a 107→3761-byte record and 123 776 bytes rewritten and fsync'd, which extrapolates to ~3.8 MB of permanent room metadata and ~125 GB of synchronous writes from a single authorized connection. A cap has to answer what happens past it, which is the other half of this ruling. One more the same review found: `is_authenticated(b"")` is true, so an **empty** actor stored in `RoomMeta` is accepted as a claimant on load — unreachable unless the auth layer can produce an empty actor, and cheap to close with whichever rule the cap takes. **The ruling is when the binding is made.** Claiming at *subscribe* closes the race for a connected owner but turns the same squat into a **read** lockout, which is strictly worse and is why C23 did not take it; claiming at Hello has the same shape one step earlier. An operator surface to release a claim makes the squat recoverable without changing when it is made, and needs a rule for who may call it (the room's creator is the obvious root). A cap on the map bounds the growth but has to say what happens past it, and the only fail-closed answer — refusing further writes — is a denial of the room. Whichever wins also decides whether the record needs an eviction story at all. **C100 is the one filing that feeds this ruling** rather than being independent of it: a branch write whose tail persists and whose head pointer does not lands durably, is reported to its author as a failure, and takes no claim — so it produces the same unclaimed-identity-with-a-stamp-in-the-room state this entry is about, by a second route. Whichever way the binding moves has to cover it. → *Server / Security*. -**C99 — an `Ops` frame carrying no ops bootstraps a room's doc-ACL authority root (crates/server) — READY. Filed by C23 (#389), found by its review; pre-dates it.** `ensure_creator` runs on any `main` write whose ingest returns `Ok`, and an empty batch does: it appends nothing, persists nothing, and answers `Ok(vec![])`. So the first authenticated actor to send a *no-op* `Ops` frame at an unestablished room becomes its creator — the doc-ACL authority root that owns `/` — without ever authoring a byte the room retains. That is the same reserve-by-no-op C23 (#389) closed one tier down for the replica-identity claim, which now requires a batch that actually landed an op, and it is the sharper of the two: a creator holds authority over the whole document where a claim holds one id space. The fix is the same shape — establish on a batch that landed — but `ensure_creator` is also reached from the replication path (a root arriving over a peer frame is judged by the same rule, deliberately), so the condition has to be stated once for both callers rather than bolted onto the client seam. → *Server / Security*. - **C100 — a branch write persists its tail and its head pointer in two steps, and reports a durable write as a failure (crates/server) — READY. Filed by C23 (#389), found by its review; pre-dates it.** `ingest_branch` calls `store.append_branch` (durable) and pushes the records into the in-memory tail, then advances the branch head through `mutate_branches(...)?`. A failure at that second persist returns `Err` over ops that are already on disk *and* already in the tail: `handle_ops` answers `failed to persist ops` and closes the connection, while the ops it disclaims will be folded into the branch and served to every subscriber. The author is told the opposite of what happened, and its natural retry resends ops the tail already holds, which dedup away — so no seam keyed on "this batch landed" ever fires for that write. C23's replica-identity claim is the first such seam and shows the shape of the damage: the write's identity stays unclaimed in a room its stamp is now in, and the owner cannot claim it by retrying (input to **C96**'s ruling). The ordering is not gratuitous — the head is `fork_point + tail.len()`, so it cannot be computed before the tail is committed — so the fix is either a single durable record covering both, or a reported outcome that distinguishes "nothing landed" from "landed, pointer stale", the latter being recoverable at the next write since the head is recomputed from the tail's length. → *Server / Durability*. **C105 — a Go mutator refused at the language's type boundary leaves the previous edit's refusal reading standing (sdks/go) — READY. Filed by C23 (#389), found by its review.** C23 routed every *inert* edit in `core::path` through one seam, so a call that resolved to nothing still opens an intention and the refusal reading answers for it rather than for the edit before. Go has three mutators that never reach that seam: `CrdtMap.Set`, `CrdtList.Insert` and `CrdtList.Append` return `marshalValue`'s error before a path is resolved, so no intention opens and `Doc.Err()` keeps reporting the *previous* edit's refusal. Narrow — each returns the type error directly, which is the answer to read at that call site, and `Doc.Err()` now says so — but it makes the two readings disagree where the surface promises they agree, and Go is the one SDK where `Doc.Err()` is load-bearing (its chaining and value-less mutators have no error return). The list-index case of the same shape *was* closed here, by letting an out-of-range delete reach the core's inert seam instead of short-circuiting; these three cannot take that route, because a value with no CRDT scalar produces no bytes to pass down. The fix is an explicit inert step on the edit boundary — a `crdtsync_doc_inert`/`crdtsync_client_inert` on the C ABI running an empty transaction, mirrored on the Go `Backend` — which is new public surface and wants its own ruling rather than being smuggled in. → *SDK / Go*. diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 757adc78..976e3568 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -517,6 +517,83 @@ impl Room { } } +/// Whether `actor` may stand as the doc-ACL authority root of a room whose server +/// sequence is `head` — the room-level half of who may be established as a root, in one +/// place, for every seam that takes an *actor's* word for one. That is +/// [`Hub::ensure_creator`], which a client's write, a peer's `Replicate` and the +/// metadata-only `ReplicateMeta` all compose through, so the rule is stated once rather +/// than bolted onto the client seam with the replication path left judging by another. +/// Two other seams install a root without passing here — a snapshot install and the +/// durable record read back off the store — and both are named below. Set-once is the +/// separate question of whether a root already stands. +/// +/// An [anonymous](crate::acl::is_authenticated) actor never may: an anonymous id is +/// ephemeral per-connection, so the set-once root would wedge the room's authority on +/// a principal that can never re-present to exercise it. +/// +/// And no actor may root a room **at sequence zero**. The root owns `/` and every +/// doc-ACL deny in the room is decided under it, so it is the heaviest authority the +/// server hands out — and a room that has reached no sequence has had nothing put in it +/// to be the authority over. Without this an empty `Ops` frame took it: the ingest +/// materialises the room, appends nothing, persists nothing and answers `Ok`, so the +/// first authenticated actor to send a no-op batch at an unestablished room owned it, +/// having authored no byte the room retains (C99). +/// +/// **This is one of two conditions, and alone it closes only half the defect.** It is a +/// rule about the *room*, so it says nothing once the room has reached a sequence — and +/// a room that holds content with **no root** is a real state, left by an anonymous +/// establishing commit or by a replica whose best-effort metadata write was lost (C55's +/// two routes). There, a frame carrying nothing satisfies this rule and would take `/` +/// over content its sender had no part in. The condition that refuses it is a statement +/// about what the *batch* offered, so it lives at the client write seam +/// (`session.rs`, `handle_ops`) rather than here: the replication callers adopt a root +/// established elsewhere and `ReplicateMeta` is by construction the frame with no batch +/// beneath it, so neither can state it and neither should. +/// +/// **The rule stops here, and the two seams it deliberately does not reach were +/// measured rather than argued.** A root also arrives *with a state* — a snapshot +/// install, and the durable record read back off the store — and at both of those the +/// sequence is not this node's count of what it accepted but a number that came in +/// beside the root — no honest catch-up even names zero, since a `Snapshot` is served +/// only below a room's floor and so at a floor of at least one, which makes a zero +/// there the mark of a buggy or hostile sender. Applying the rule to those two refuses +/// that sender nothing (it names a nonzero floor instead) and breaks two things that +/// hold today: a state +/// full of content whose frame names floor zero comes up **rootless**, leaving every +/// tuple in it with no authority to be decided under, and a room a state transfer left +/// at zero loses the root it was legitimately established with on its next reload. Both +/// are the C29 hole reached from the other side, which is worse than the reservation +/// this rule exists to refuse. Pinned by +/// `a_state_installed_at_sequence_zero_still_carries_the_frames_root` and +/// `a_durable_root_survives_a_reload_of_a_room_left_at_sequence_zero`. +/// +/// So the sequence this rule reads is only as honest as the floor the room came up at: +/// a peer whose state transfer named a nonzero floor over empty bytes leaves a room +/// this rule then lets the next actor root. That peer is inside the room's replica set +/// and could assert the root itself, so the rule is not what stands between it and the +/// room — what it refuses is the actor with nothing but a client connection, which is +/// the reach the defect had. +/// +/// The rule is about the **room**, not about the batch that reached it. A write that +/// the room's dedup swallows whole still roots a room that already holds ops (the shape +/// C55 built [`Message::ReplicateMeta`](crdtsync_core::Message::ReplicateMeta) to +/// replicate), deliberately — and the line is drawn at what the batch *presented*, not +/// at what landed. A resend presents the room's content, which only a replica holding +/// it can do and which an attacker must therefore obtain; an empty frame presents +/// nothing and costs its sender nothing, which is why the two are not the same act +/// however alike their effect on the log. The refusal of the resend would still buy +/// attributability — the fresh-op route leaves an op under the taker's `ClientId` and +/// claims that identity, the deduped route leaves the room's state with no record of +/// who took `/` — and what it would cost is C55's route 1, whose only shape is a root +/// established by a write the dedup swallowed whole, against an attacker who pays one +/// op to route around it. C23 ruled the other way one tier down, for the +/// replica-identity claim, where the act *is* the taking of an identity another +/// replica's historic ops wrote; a root is the room's, not another actor's, so the same +/// act does not carry the same theft. +fn may_stand_as_root(actor: &[u8], head: u64) -> bool { + crate::acl::is_authenticated(actor) && head > 0 +} + /// What a subscriber needs to catch up, given the sequence it last saw. pub enum Catchup { /// The subscriber is at or above the compaction floor: fold these ops, in @@ -1127,7 +1204,10 @@ impl Hub { } // The stored bytes are supplied by whoever hands the store over, so the // root they name is checked here as one off a frame is: an anonymous id - // could never re-present to exercise the ownership it would be handed. + // could never re-present to exercise the ownership it would be handed. The + // room's sequence decides nothing here — this is a root the node itself + // recorded, and a room the state transfer left at zero still holds the + // authority its content was written under ([`may_stand_as_root`]). if let Some(creator) = meta.creator.filter(|a| crate::acl::is_authenticated(a)) { if let Some(r) = self.rooms.get_mut(&room) { if r.creator.is_none() { @@ -1692,6 +1772,14 @@ impl Hub { /// actor — so a re-sent snapshot that names none leaves the standing root alone /// rather than dropping the authority every deny in the state is decided under. /// + /// The room's sequence decides nothing here, deliberately: this frame installs the + /// content *and* names the floor it lands at, so requiring a nonzero one would + /// refuse a root over a state full of content whose sender named zero — leaving the + /// tuples in that state with no authority to be decided under — while refusing + /// nothing, since a sender that wanted the root would name one instead. See + /// [`may_stand_as_root`], which is why that rule stops at the seams whose sequence + /// is this node's own count. + /// /// The room's op-version high-water is *preserved*, never supplied here: it is the /// all-time worst case a joiner must down-reach rather than a property of the /// installed state, so replacing the replica leaves it exactly as it leaves it @@ -2474,14 +2562,18 @@ impl Hub { /// Record `actor` as `room`'s creator if it has none yet, persisting the durable /// metadata. Set-once: a room keeps its first writer as creator, so a later - /// caller never displaces it. A no-op for an unknown room, and for an - /// [anonymous](crate::acl::is_authenticated) actor — an anonymous id is ephemeral - /// per-connection, so set-once would wedge the room's authority on a principal - /// that can never re-present to exercise it. Both rules decide a root arriving with - /// an installed snapshot and one read back off the store too, so a root is judged - /// the same whichever seam carries it. An install expresses set-once by composing - /// against the standing root rather than guarding on its absence; the answer is - /// the same either way. + /// caller never displaces it. A no-op for an unknown room, and for an actor + /// [`may_stand_as_root`] refuses — an anonymous one, and any actor at a room at + /// sequence zero, so a no-op frame cannot mint a room and own it (C99). This is the + /// seam every *actor-asserted* root composes through — a client's write, a peer's + /// `Replicate`, and the metadata-only `ReplicateMeta` — so all three are judged + /// alike. It is not the whole of C99's answer: refusing a no-op frame at a room + /// that already holds content is a statement about the batch, which only the client + /// seam can make, and it makes it there. Set-once and the anonymous rule decide a root arriving with an + /// installed snapshot and one read back off the store too; the sequence rule does + /// not, and [`may_stand_as_root`] records why. An install expresses set-once by + /// composing against the standing root rather than guarding on its absence; the + /// answer is the same either way. /// /// Persisting is best-effort, matching the governing metadata: a failed write does /// not fail the caller's write. Set-once means nothing retries it either, so a @@ -2495,11 +2587,8 @@ impl Hub { /// tell the rest of the cluster can — a root established by a write the room's /// dedup swallowed whole has no op batch to ride out on. pub fn ensure_creator(&mut self, room: &[u8], actor: &[u8]) -> bool { - if !crate::acl::is_authenticated(actor) { - return false; - } let established = match self.rooms.get_mut(room) { - Some(r) if r.creator.is_none() => { + Some(r) if r.creator.is_none() && may_stand_as_root(actor, r.head()) => { r.creator = Some(actor.to_vec()); true } diff --git a/crates/server/src/registry.rs b/crates/server/src/registry.rs index 75fbdef7..7c3eb2e2 100644 --- a/crates/server/src/registry.rs +++ b/crates/server/src/registry.rs @@ -796,13 +796,19 @@ impl Registry { if ops.is_empty() { // Nothing to catch up but the room's root, and only where there // is one — a rootless room has nothing this frame could assert. - // Nor where the room has reached no sequence at all: a follower - // holds no such room and this frame does not create one, so the - // root would be re-sent inertly on every dial forever. A room - // with no ops also has no ACL tuples for a root to decide, so - // there is nothing to repair. It is reachable — an authenticated - // `Ops` frame carrying no ops roots a zero-op room (C99) — which - // is why it is a case rather than an impossibility. + // Nor where the room has reached no sequence at all, where the root + // would be re-sent inertly on every dial: this frame creates no + // room, so a follower holding none is unreachable by it, and one + // that does hold a zero-sequence room refuses the root on arrival — + // `Hub::ensure_creator` establishes none there (C99), which is the + // same rule read from the receiving end. + // + // Inert, then, rather than unnecessary. A zero-sequence room is not + // the empty thing it reads as: a state install takes its floor from + // its frame and its content from its bytes, so such a room can hold + // a whole document, doc-ACL tuples included, and a replica of one + // that lost its root has a repair neither this dial nor the frame + // can carry (C141). if meta.creator.is_none() || self.hub.seq(room) == 0 { return None; } diff --git a/crates/server/src/session.rs b/crates/server/src/session.rs index cc2f84fd..93d55e55 100644 --- a/crates/server/src/session.rs +++ b/crates/server/src/session.rs @@ -1954,14 +1954,37 @@ fn handle_ops( // Whether this write is what rooted the room, reported out so the leader can // replicate a root its op batch does not carry. let mut root_established = false; + // Whether the batch presented any op at all — not whether one landed. This is the + // evidence a *client* offers for being the room's author, and the two differ: a + // resend the room dedups whole still presented the room's content, which is + // something only a replica that holds it can do, while an empty frame presents + // nothing and costs its sender nothing. + let submitted_ops = !ops.is_empty(); let applied = if branch == MAIN_BRANCH { let applied = hub.ingest(&room, ops, write_version); // The first authenticated actor to write a room establishes it, so it becomes - // the room's creator — the doc-ACL authority root that owns `/`. Set-once and - // authenticated-only, both decided by `ensure_creator` so a root arriving over - // a replication frame is judged by the same rule. A branch write presupposes - // an already-established (forked) room, so it never bootstraps a creator. - if applied.is_ok() { + // the room's creator — the doc-ACL authority root that owns `/`. Two conditions + // decide it, and they close the same defect from opposite sides (C99). + // + // The room must have reached a sequence, which lives in `ensure_creator` so + // that a root arriving over a replication frame is judged by it too — the ingest + // above materialises the room and answers `Ok` for an empty batch just as for a + // landed one, so without it a no-op frame *created* a room and owned it. + // + // And the batch must have presented an op, which lives here because it is a + // statement about what a client offered. A rootless room that already holds + // content is a real state — an anonymous establishing commit leaves one, and so + // does a replica whose metadata write was lost — and there the room-level + // condition is already satisfied, so a frame carrying nothing would take `/` + // over content it had no part in. The replication callers state no such + // condition and must not: they *adopt* a root established elsewhere, and + // `Message::ReplicateMeta` is by construction the frame with no batch beneath + // it. A peer asserting a root it did not earn is refused by the replica-set + // gate, not by this one. + // + // A branch write presupposes an already-established (forked) room, so it never + // bootstraps a creator. + if applied.is_ok() && submitted_ops { root_established = hub.ensure_creator(&room, identity.actor()); } applied diff --git a/crates/server/tests/creator_establishment.rs b/crates/server/tests/creator_establishment.rs new file mode 100644 index 00000000..278a15f7 --- /dev/null +++ b/crates/server/tests/creator_establishment.rs @@ -0,0 +1,802 @@ +//! A room's doc-ACL authority root is established only over a room that has reached a +//! sequence (C99). +//! +//! The creator owns `/`: every doc-ACL grant in the room confers authority only if it +//! traces back to it, and the room's denies decide nothing without it. It is the +//! heaviest authority the server hands out, and it was established by *any* `main` +//! write whose ingest answered `Ok` — which an empty batch does. The ingest +//! materialises the room, appends nothing, persists nothing and returns `Ok(vec![])`, +//! so the first authenticated actor to send a no-op `Ops` frame at an unestablished +//! room owned it, having authored no byte the room retains. That is reserve-by-no-op, +//! the shape C23 (#389) closed one tier down for the replica-identity claim, and the +//! sharper of the two: a claim holds one id space, a creator holds the document. +//! +//! The rule that closes it is stated once, in `may_stand_as_root`, at the one seam +//! every *establishment* composes through — `Hub::ensure_creator`, which a client's +//! write, a peer's `Replicate` and the metadata-only `ReplicateMeta` all reach — rather +//! than bolted onto the client seam with the replication path left judging by another. +//! So the condition is about the **room**: no actor roots a room at sequence zero. +//! *Sequence*, not retention: at this seam they are one fact, because the sequence is +//! this node's own count of what it accepted. +//! +//! **Where the rule stops is measured rather than argued.** A root also arrives *with a +//! state* — a snapshot install, and the record read back off the store — and there the +//! sequence came in beside the root instead of being counted here. Extending the rule +//! to those two refuses nothing (a sender that wants the root names a nonzero floor) +//! and breaks two things: content installed at floor zero comes up rootless, and a room +//! a state transfer left at zero loses its legitimate root on the next reload. Both are +//! pinned below, as the reason the rule ends where it does. +//! +//! Which leaves a write the room's dedup swallows whole still rooting a room that +//! already holds ops, and that is deliberate rather than residue — see +//! `an_authenticated_resend_roots_a_room_that_already_retains_a_write`. It is the shape +//! C55 (#397) built `ReplicateMeta` to replicate, and refusing it would buy nothing: +//! whoever may resend a room's ops may land one fresh op and root it that way. +//! +//! The negatives here read as an absence — no root where one used to land. Each is +//! paired with the positive that shows the same seam still establishes a root when the +//! room has reached a sequence, so a seam that simply stopped working would fail this +//! file. + +use std::sync::{Arc, Mutex}; + +use crdtsync_core::acl::{AclGrant, AclSubject, Capability}; +use crdtsync_core::path::encode_path; +use crdtsync_core::protocol::Channel; +use crdtsync_core::{AclEffect, ClientId, Document, Message, Op, Scalar}; +use crdtsync_server::acl::{actor_key, Acl}; +use crdtsync_server::membership::Membership; +use crdtsync_server::store::Store; +use crdtsync_server::{ + ConnId, Hub, Identity, ManualClock, Registry, RoomLog, RoomMeta, SchemaRegistry, StaticTokens, + StoredOp, +}; + +const CH: Channel = Channel(0); +const ROOM: &[u8] = b"room-a"; +const N: usize = 3; +const APP: &[u8] = b"collab"; + +/// The key alice denies bob, and one she leaves readable. +const SECRET: &[u8] = b"secret"; +const OPEN: &[u8] = b"open"; + +/// Read to any authenticated actor, so the doc-ACL deny below is the only thing that +/// can narrow bob's read — and the narrowing needs the room's root to be alice. +const SCHEMA: &str = r#"{ "schema": "collab", "version": 1, "root": "R", + "types": { "R": { "kind": "map" } }, + "auth": { + "roles": ["editor"], + "grants": [ + { "allow": "read", "to": "authenticated", "on": "/" }, + { "allow": "write", "to": "authenticated", "on": "/" } + ] + } }"#; + +const CLUSTER_SECRET: &[u8] = b"peer-plane-cluster-secret-for-tests"; + +fn cid(first: u8) -> ClientId { + let mut b = [0u8; 16]; + b[0] = first; + ClientId::from_bytes(b) +} + +fn sub(room: &[u8]) -> Message { + Message::Subscribe { + channel: CH, + room: room.to_vec(), + branch: Vec::new(), + zone: Vec::new(), + last_seen_seq: 0, + } +} + +fn empty_ops() -> Message { + Message::Ops { + channel: CH, + ops: Vec::new(), + } +} + +fn refusal(m: &Message) -> bool { + matches!(m, Message::Error { .. } | Message::OpsRejected { .. }) +} + +// --- the client write seam --- + +/// A connection on `r` declaring `client`, authenticated as `actor` — the default +/// `AllowAll` verifier adopts the credential bytes as the actor — subscribed to `ROOM`. +fn writer(r: &mut Registry, client: u8, actor: &[u8]) -> ConnId { + let id = r.connect(); + assert!(r.deliver( + id, + Message::Hello { + client: cid(client), + app_id: Vec::new(), + schema_version: 0, + codecs: Vec::new(), + } + )); + assert!(r.deliver( + id, + Message::Auth { + credential: actor.to_vec() + } + )); + assert!(r.deliver(id, sub(ROOM))); + r.take_outbox(id); + id +} + +/// The same, over a connection the deployment admitted with no credential, under a +/// minted `anon:`-prefixed actor — one no seam accepts as a root. +fn anonymous_writer(r: &mut Registry, client: u8) -> ConnId { + let id = r.connect_authenticated(Identity::new(b"anon:ghost".to_vec())); + assert!(r.deliver( + id, + Message::Hello { + client: cid(client), + app_id: Vec::new(), + schema_version: 0, + codecs: Vec::new(), + } + )); + assert!(r.deliver(id, sub(ROOM))); + r.take_outbox(id); + id +} + +/// A one-op batch setting `key`, authored under `client`'s own fresh replica. +fn batch(client: u8, key: &[u8]) -> Vec { + Document::new(cid(client)).transact(|tx| tx.set(key, Scalar::Int(1))) +} + +#[test] +fn an_empty_ops_frame_does_not_root_the_room_it_opens() { + // The defect, at the wire: an authenticated actor sends a batch with no ops at a + // room nobody has established, and owns `/` of it for good. + let mut r = Registry::new(cid(0xFF)); + let mallory = writer(&mut r, 1, b"mallory"); + assert!(r.deliver(mallory, empty_ops())); + + assert_eq!(r.hub().seq(ROOM), 0, "the no-op batch retained nothing"); + assert_eq!( + r.hub().room_creator(ROOM), + None, + "a batch that put nothing in the room took its authority root", + ); + + // And the reservation does not outlive the room's first real write: the actor that + // actually establishes the room gets it. + let alice = writer(&mut r, 2, b"alice"); + assert!(r.deliver( + alice, + Message::Ops { + channel: CH, + ops: batch(2, OPEN) + } + )); + assert_eq!( + r.hub().room_creator(ROOM).as_deref(), + Some(b"alice".as_slice()), + ); +} + +#[test] +fn an_empty_ops_frame_is_not_refused() { + // The fix is a narrower establishment rule, not a rejection. An inert edit frames + // an `Ops` batch with no ops, and refusing one would disconnect an honest client + // from a room it merely made a no-op edit in. + let mut r = Registry::new(cid(0xFF)); + let alice = writer(&mut r, 1, b"alice"); + assert!( + r.deliver(alice, empty_ops()), + "the no-op write kept the connection", + ); + let out = r.take_outbox(alice); + assert!( + !out.iter().any(refusal), + "the no-op write was refused: {out:?}" + ); + + // And the same connection goes on to root the room with its next real write. + assert!(r.deliver( + alice, + Message::Ops { + channel: CH, + ops: batch(1, OPEN) + } + )); + assert_eq!( + r.hub().room_creator(ROOM).as_deref(), + Some(b"alice".as_slice()), + ); +} + +#[test] +fn an_authenticated_resend_roots_a_room_that_already_retains_a_write() { + // The scope of the rule, pinned: it is about the room, not about the batch. A + // write the dedup swallows whole still roots a room that holds ops — the state + // C55's `ReplicateMeta` exists to replicate. Refusing it would buy nothing, since + // whoever can resend the room's ops can land one fresh op and root it that way, + // and there is no form of "this batch landed" the `ReplicateMeta` seam could state. + let mut r = Registry::new(cid(0xFF)); + let ghost = anonymous_writer(&mut r, 1); + let ops = batch(1, OPEN); + assert!(r.deliver( + ghost, + Message::Ops { + channel: CH, + ops: ops.clone() + } + )); + assert_eq!( + r.hub().room_creator(ROOM), + None, + "an anonymous actor roots nothing", + ); + + let alice = writer(&mut r, 1, b"alice"); + let before = r.hub().seq(ROOM); + assert!(r.deliver(alice, Message::Ops { channel: CH, ops })); + assert_eq!( + r.hub().seq(ROOM), + before, + "the resend landed no op — the room deduped it whole", + ); + assert_eq!( + r.hub().room_creator(ROOM).as_deref(), + Some(b"alice".as_slice()), + "a room that retains a write is rooted by the resend", + ); +} + +#[test] +fn an_empty_frame_does_not_take_a_rootless_rooms_authority() { + // The room-level condition alone leaves the defect standing where it bites hardest. + // A room that holds content and has no root is a real state, not a corner: an + // anonymous establishing commit leaves one (C55 route 1), and so does a replica + // whose best-effort metadata write was lost (route 2). There the room has long + // reached a sequence, so a frame carrying *nothing* would satisfy the room rule and + // take `/` over content its sender had no part in — needing no knowledge of the + // room at all, unlike the resend the rule does admit. + let mut r = Registry::new(cid(0xFF)); + let ghost = anonymous_writer(&mut r, 1); + assert!(r.deliver( + ghost, + Message::Ops { + channel: CH, + ops: batch(1, OPEN) + } + )); + assert_eq!( + r.hub().room_creator(ROOM), + None, + "an anonymous actor roots nothing, so the room holds content and no root", + ); + let before = r.hub().seq(ROOM); + assert!(before > 0, "the room holds content"); + + let mallory = writer(&mut r, 2, b"mallory"); + assert!(r.deliver(mallory, empty_ops())); + assert_eq!(r.hub().seq(ROOM), before, "the frame carried nothing"); + assert_eq!( + r.hub().room_creator(ROOM), + None, + "a frame carrying nothing took the room's authority", + ); + + // And the room's own author still can: the refusal narrows who establishes the + // root, it does not lock the room out of ever having one. + let alice = writer(&mut r, 3, b"alice"); + assert!(r.deliver( + alice, + Message::Ops { + channel: CH, + ops: batch(3, SECRET) + } + )); + assert_eq!( + r.hub().room_creator(ROOM).as_deref(), + Some(b"alice".as_slice()), + ); +} + +#[test] +fn a_batch_of_ops_no_replica_can_hold_presents_nothing_either() { + // What makes "presented an op" more than a formality: a batch whose ops are + // inadmissible lands nothing and costs its sender nothing to fabricate, so if it + // reached the establishment seam it would be the empty frame under another name. + // It does not — the session refuses such a batch recoverably before the ingest — + // and that ordering is what this pins, since a reader has no other way to tell + // whether the condition above can be satisfied for free. + let mut r = Registry::new(cid(0xFF)); + let ghost = anonymous_writer(&mut r, 1); + assert!(r.deliver( + ghost, + Message::Ops { + channel: CH, + ops: batch(1, OPEN) + } + )); + let before = r.hub().seq(ROOM); + assert!(before > 0 && r.hub().room_creator(ROOM).is_none()); + + // An op whose stamp names a client other than its author: admissible-forever's + // complement, a pure function of the op, so every replica refuses it. + let mallory = writer(&mut r, 2, b"mallory"); + let mut ops = batch(2, SECRET); + for op in ops.iter_mut() { + op.stamp.client = cid(9); + } + assert!(r.deliver(mallory, Message::Ops { channel: CH, ops })); + let out = r.take_outbox(mallory); + assert!( + out.iter().any(refusal), + "the batch was admitted rather than refused: {out:?}", + ); + assert_eq!(r.hub().seq(ROOM), before, "it landed nothing"); + assert_eq!( + r.hub().room_creator(ROOM), + None, + "a batch no replica can hold took the room's authority", + ); +} + +// --- what the reservation was worth: authority over the document --- + +/// A single node holding `SCHEMA` and an abstaining deployment ACL, so the schema and +/// doc-ACL tiers alone decide every read. +fn schema_node() -> Registry { + let mut r = Registry::new(cid(0xFF)); + let mut sr = SchemaRegistry::new(); + sr.register(APP, 1, SCHEMA.as_bytes(), b"").unwrap(); + r.set_schema_registry(Arc::new(Mutex::new(sr))); + let mut t = StaticTokens::new(); + for actor in [ + b"alice".as_slice(), + b"bob".as_slice(), + b"mallory".as_slice(), + ] { + t.insert_identity( + [b"t-".as_slice(), actor].concat(), + Identity::with_claims(actor.to_vec(), Vec::new(), Vec::new()), + ); + } + r.set_verifier(Box::new(t)); + r.set_authorizer(Box::new(Acl::new())); + r.set_clock(Arc::new(ManualClock::new(0))); + r +} + +/// Hello + Auth a connection as `credential`, declaring `{APP, 1}`. +fn hello_auth(r: &mut Registry, client: u8, credential: &str) -> ConnId { + let id = r.connect(); + assert!(r.deliver( + id, + Message::Hello { + client: cid(client), + app_id: APP.to_vec(), + schema_version: 1, + codecs: Vec::new(), + } + )); + assert!(r.deliver( + id, + Message::Auth { + credential: credential.as_bytes().to_vec(), + } + )); + r.take_outbox(id); + id +} + +/// The room's establishing commit: two keys and a `Deny(Read)` for **mallory** at +/// `/secret`, naming alice as its grantor. +/// +/// The denied actor is the squatter deliberately. A deny beats the schema tier's static +/// grant whoever wrote it — `acl::denies_path` never drops one for an unrooted grantor — +/// so a deny aimed at a third party decides the same either way and would pin nothing +/// about the root. What only the root has is the *exemption*: the creator auto-owns `/`, +/// so no deny in the room reaches it. Aiming the deny at mallory is therefore the one +/// reading that moves when the reservation succeeds. +fn establishing_batch() -> Vec { + Document::new(cid(2)).transact(|tx| { + tx.register(OPEN, Scalar::Int(1)); + tx.register(SECRET, Scalar::Int(2)); + tx.acl().grant( + AclSubject::Actor(actor_key(b"mallory")), + AclGrant::Capability(Capability::Read), + AclEffect::Deny, + encode_path(&[SECRET]), + actor_key(b"alice"), + ); + }) +} + +/// Subscribe `credential` on a fresh connection and fold whatever the catch-up served +/// it into one document. +fn reads(r: &mut Registry, client: u8, credential: &str) -> Document { + let id = hello_auth(r, client, credential); + assert!(r.deliver(id, sub(ROOM))); + let out = r.take_outbox(id); + assert!( + !out.iter().any(|m| matches!(m, Message::Error { .. })), + "the schema's authenticated read grant admits the reader: {out:?}", + ); + let mut view = Document::new(cid(client)); + let mut served = false; + for msg in out { + match msg { + Message::Ops { ops, .. } => { + served = true; + for op in &ops { + view.apply(op); + } + } + Message::Snapshot { state, .. } => { + served = true; + view = Document::decode_state(&state).expect("a served snapshot decodes"); + } + _ => {} + } + } + assert!(served, "the reader was served a catch-up"); + view +} + +#[test] +fn a_no_op_reservation_does_not_take_the_documents_authority_from_its_author() { + // The consequence the root carries, rather than the field. The creator auto-owns + // `/`, so it is exempt from every deny the room holds — which is the privilege a + // no-op reservation stole. Mallory reserves the room with a batch of no ops, alice + // establishes it and denies mallory `/secret`, and mallory reads: denied, because + // the room's root is its author. Had the reservation stood, mallory would own `/` + // and read straight through alice's deny. + let mut r = schema_node(); + let mallory = hello_auth(&mut r, 1, "t-mallory"); + assert!(r.deliver(mallory, sub(ROOM))); + r.take_outbox(mallory); + assert!(r.deliver(mallory, empty_ops())); + + let alice = hello_auth(&mut r, 2, "t-alice"); + assert!(r.deliver(alice, sub(ROOM))); + r.take_outbox(alice); + assert!(r.deliver( + alice, + Message::Ops { + channel: CH, + ops: establishing_batch() + } + )); + let out = r.take_outbox(alice); + assert!( + !out.iter().any(refusal), + "alice's write was refused: {out:?}" + ); + + assert_eq!( + r.hub().room_creator(ROOM).as_deref(), + Some(b"alice".as_slice()), + "the room's authority root is the actor that established it", + ); + let view = reads(&mut r, 7, "t-mallory"); + assert!( + view.get(OPEN).is_some(), + "mallory keeps the read the schema grants her", + ); + assert!( + view.get(SECRET).is_none(), + "the squatter read through the deny, so it holds `/` after all", + ); +} + +#[test] +fn a_later_writer_does_not_displace_the_root() { + // The other half of the guard the rule shares: set-once. A room the reservation + // failed to take is rooted by its first landed write and stays there, so the + // squatter's *real* write afterwards inherits nothing. + let mut r = Registry::new(cid(0xFF)); + let mallory = writer(&mut r, 1, b"mallory"); + assert!(r.deliver(mallory, empty_ops())); + + let alice = writer(&mut r, 2, b"alice"); + assert!(r.deliver( + alice, + Message::Ops { + channel: CH, + ops: batch(2, OPEN) + } + )); + assert_eq!( + r.hub().room_creator(ROOM).as_deref(), + Some(b"alice".as_slice()), + ); + + assert!(r.deliver( + mallory, + Message::Ops { + channel: CH, + ops: batch(1, SECRET) + } + )); + assert_eq!( + r.hub().room_creator(ROOM).as_deref(), + Some(b"alice".as_slice()), + "a later write displaced the room's root", + ); +} + +// --- the replication seams --- + +fn members() -> String { + (0..7) + .map(|i| format!("10.0.0.{i}:9000")) + .collect::>() + .join(",") +} + +fn membership_for(self_addr: &str) -> Membership { + Membership::from_static_config(None, Some(self_addr), &members(), N).unwrap() +} + +/// A room this node holds as a *follower* — in the replica set but not its head, so a +/// leader's frames for it apply here. +fn followed_room(m: &Membership) -> Vec { + for i in 0..1_000_000 { + let room = format!("room-{i}").into_bytes(); + let r = m.replicas_for(&room); + if r.len() >= 2 && !m.is_self(&r[0]) && r.iter().skip(1).any(|n| m.is_self(n)) { + return room; + } + } + panic!("no room places self as a follower"); +} + +/// A follower node, and a peer link on it admitted as one of `room`'s other replicas. +fn follower(room: &[u8]) -> (Registry, ConnId) { + let mut r = Registry::new(cid(0xFF)); + r.set_clock(Arc::new(ManualClock::new(0))); + r.set_membership(membership_for("10.0.0.6:9000")); + r.set_cluster_secret(CLUSTER_SECRET.to_vec()); + let node = r + .membership() + .and_then(|m| m.replicas_for(room).into_iter().find(|n| !m.is_self(n))) + .expect("the room has another replica"); + let peer = r.connect(); + assert!(r.deliver( + peer, + Message::PeerAuth { + node: node.as_bytes().to_vec(), + secret: CLUSTER_SECRET.to_vec(), + }, + )); + (r, peer) +} + +/// A leader's `Replicate` for `room`'s main stream carrying `ops` and asserting +/// `creator` as the room's root. +fn replicate(room: &[u8], ops: Vec, creator: &[u8]) -> Message { + Message::Replicate { + room: room.to_vec(), + branch: b"main".to_vec(), + ops, + base_seq: 0, + epoch: 1, + creator: Some(creator.to_vec()), + governing: None, + max_op_version: None, + } +} + +fn replicate_meta(room: &[u8], creator: &[u8]) -> Message { + Message::ReplicateMeta { + room: room.to_vec(), + epoch: 1, + creator: Some(creator.to_vec()), + } +} + +#[test] +fn an_empty_replicate_frame_does_not_root_the_replica_it_creates() { + // The same reserve-by-no-op one seam over: a `Replicate` carrying no ops is + // ingested exactly as a client's empty batch is, creating the room here, and its + // asserted root landed on a replica that retains nothing. + let m = membership_for("10.0.0.6:9000"); + let room = followed_room(&m); + let (mut r, peer) = follower(&room); + + assert!(r.deliver(peer, replicate(&room, Vec::new(), b"mallory"))); + assert!( + r.hub().holds_room(&room), + "the empty frame reached the ingest and created the room", + ); + assert_eq!(r.hub().seq(&room), 0, "the frame carried nothing"); + assert_eq!( + r.hub().room_creator(&room), + None, + "a frame with no ops beneath it rooted the replica", + ); +} + +#[test] +fn a_replicate_frame_that_lands_ops_roots_the_replica() { + // The other side of the same seam: C29's root-rides-the-frame still holds, so the + // rule above narrowed the establishment rather than stopping it. + let m = membership_for("10.0.0.6:9000"); + let room = followed_room(&m); + let (mut r, peer) = follower(&room); + + assert!(r.deliver(peer, replicate(&room, batch(1, OPEN), b"alice"))); + assert_eq!(r.hub().seq(&room), 1); + assert_eq!( + r.hub().room_creator(&room).as_deref(), + Some(b"alice".as_slice()), + ); +} + +#[test] +fn a_replicate_meta_does_not_root_a_replica_that_retains_nothing() { + // `ReplicateMeta` is the arm with no batch at all, which is why the condition is + // stated about the room. It is inert for a room this node does not hold, so the + // shape that reaches the rule is a room held and empty — which an empty + // `Replicate` leaves behind. + let m = membership_for("10.0.0.6:9000"); + let room = followed_room(&m); + let (mut r, peer) = follower(&room); + + assert!(r.deliver(peer, replicate(&room, Vec::new(), b"mallory"))); + assert!( + r.hub().holds_room(&room), + "the empty frame created the room" + ); + assert!(r.deliver(peer, replicate_meta(&room, b"mallory"))); + assert_eq!( + r.hub().room_creator(&room), + None, + "the metadata frame rooted a replica that retains nothing", + ); +} + +#[test] +fn a_replicate_meta_roots_a_replica_that_holds_the_rooms_ops() { + // C55's repair still lands: a follower converged on the room's ops and missing its + // root is re-rooted by the metadata frame. + let m = membership_for("10.0.0.6:9000"); + let room = followed_room(&m); + let (mut r, peer) = follower(&room); + + assert!(r.deliver( + peer, + Message::Replicate { + room: room.to_vec(), + branch: b"main".to_vec(), + ops: batch(1, OPEN), + base_seq: 0, + epoch: 1, + creator: None, + governing: None, + max_op_version: None, + } + )); + assert_eq!(r.hub().room_creator(&room), None); + assert!(r.deliver(peer, replicate_meta(&room, b"alice"))); + assert_eq!( + r.hub().room_creator(&room).as_deref(), + Some(b"alice".as_slice()), + ); +} + +// --- the install and store seams --- + +/// A whole-replica snapshot of a room holding one write. +fn state_with_a_write() -> Vec { + let mut hub = Hub::new(cid(0xFF)); + hub.ingest(ROOM, batch(1, OPEN), None).unwrap(); + hub.export_room(ROOM).expect("the room exists") +} + +/// A snapshot of a room holding nothing. +fn empty_state() -> Vec { + Document::new(cid(0xFF)).encode_state() +} + +#[test] +fn a_state_installed_at_sequence_zero_still_carries_the_frames_root() { + // Where the rule stops, and why — measured, not argued. This seam takes both the + // content and the floor it lands at from the same frame, so refusing a root at + // sequence zero refuses nothing (a sender that wants it names a nonzero floor) and + // lands a room full of content with no authority its tuples are decided under: the + // C29 hole, reached from the other side and strictly worse than the reservation. + let mut hub = Hub::new(cid(0xFF)); + hub.install_snapshot(ROOM, &state_with_a_write(), 0, Some(b"alice".to_vec())) + .expect("the state decodes"); + assert!(hub.get(ROOM, OPEN).is_some(), "the content installed"); + assert_eq!( + hub.room_creator(ROOM).as_deref(), + Some(b"alice".as_slice()), + "content came up rootless, so every deny in it decides nothing", + ); +} + +#[test] +fn a_snapshot_landing_at_sequence_zero_leaves_a_standing_root_alone() { + // The one shape that still holds a root over a room at sequence zero, and it must: + // dropping the standing root here would let a peer strip a room's authority — and + // with it every deny in the room — by sending an empty state at sequence zero. + let mut hub = Hub::new(cid(0xFF)); + hub.ingest(ROOM, batch(1, OPEN), None).unwrap(); + assert!(hub.ensure_creator(ROOM, b"alice")); + hub.install_snapshot(ROOM, &empty_state(), 0, None) + .expect("the state decodes"); + assert_eq!(hub.room_creator(ROOM).as_deref(), Some(b"alice".as_slice())); +} + +/// A stored record for `ROOM` naming `actor` as its root, over `ops`. +fn stored(actor: &[u8], ops: Vec) -> RoomLog { + RoomLog { + ops: ops.into_iter().map(|op| StoredOp::new(op, None)).collect(), + meta: Some(RoomMeta { + governing: None, + max_op_version: None, + creator: Some(actor.to_vec()), + client_actors: Vec::new(), + }), + ..RoomLog::default() + } +} + +#[test] +#[cfg_attr(miri, ignore)] // drives the room store on the filesystem +fn a_durable_root_survives_a_reload_of_a_room_left_at_sequence_zero() { + // The other seam the rule stops short of, for the same reason read one restart + // later. A state transfer landing at sequence zero leaves the standing root alone + // — and persists it — so a load that judged the record by the room's sequence would + // drop a root the node itself established over content, handing `/` to whoever + // writes next. + let dir = std::env::temp_dir().join(format!("c99-reload-{}", std::process::id())); + let _ = std::fs::remove_dir_all(&dir); + { + let mut hub = Hub::new(cid(0xFF)); + hub.attach_store(Store::open(&dir).expect("the store opens")); + hub.ingest(ROOM, batch(1, OPEN), None) + .expect("the write lands"); + assert!(hub.ensure_creator(ROOM, b"alice")); + // The install lands *content* at floor zero, so what a reload would drop the + // root over is a room holding a whole document — the inert-deny harm, not a + // bookkeeping one. An empty state here would prove only that a root over + // nothing survives a restart, which is the reservation this unit refuses. + hub.install_snapshot(ROOM, &state_with_a_write(), 0, None) + .expect("the state decodes"); + assert_eq!(hub.seq(ROOM), 0); + assert!(hub.get(ROOM, OPEN).is_some(), "the room holds content"); + } + let hub = Hub::from_rooms( + cid(0xFF), + Store::open(&dir) + .expect("the store opens") + .load() + .expect("it loads"), + ) + .expect("the record loads"); + let root = hub.room_creator(ROOM); + let content = hub.get(ROOM, OPEN).is_some(); + let _ = std::fs::remove_dir_all(&dir); + assert!(content, "the room came back holding its content"); + assert_eq!( + root.as_deref(), + Some(b"alice".as_slice()), + "the reload dropped a root over content the room still holds, leaving every deny in it inert", + ); +} + +#[test] +fn a_stored_root_over_a_room_that_retains_a_write_comes_back() { + let hub = Hub::from_rooms( + cid(0xFF), + vec![(ROOM.to_vec(), stored(b"alice", batch(1, OPEN)))], + ) + .expect("the record loads"); + assert_eq!(hub.seq(ROOM), 1); + assert_eq!(hub.room_creator(ROOM).as_deref(), Some(b"alice".as_slice())); +} diff --git a/crates/server/tests/creator_replication_repair.rs b/crates/server/tests/creator_replication_repair.rs index 13c1b5a6..303d38c2 100644 --- a/crates/server/tests/creator_replication_repair.rs +++ b/crates/server/tests/creator_replication_repair.rs @@ -6,11 +6,12 @@ //! absent on another, with nothing scheduled to close the gap: //! //! 1. **A root established by a write that broadcast nothing.** `ensure_creator` fires -//! on any `Ok` from `Hub::ingest`, a batch the room's dedup swallowed whole -//! included, while replication is enqueued only for a *non-empty* broadcast. A room -//! whose establishing commit was anonymous (root `None`, replicated as such) and -//! whose next authenticated write is a pure resend of ops the hub already holds -//! gains its root on the leader with no frame to carry it. The client reaches that +//! on any `Ok` from `Hub::ingest` at a room that retains a write (C99), a batch the +//! room's dedup swallowed whole included, while replication is enqueued only for a +//! *non-empty* broadcast. A room whose establishing commit was anonymous (root +//! `None`, replicated as such) and whose next authenticated write is a pure resend +//! of ops the hub already holds gains its root on the leader with no frame to carry +//! it. The client reaches that //! state by *reconnecting*, not by authenticating in place: an anonymously-admitted //! connection already holds an identity, so an in-band `Auth` on it is refused as a //! protocol violation. It writes anonymously, reconnects under its credential with @@ -711,22 +712,45 @@ fn a_re_rooted_replica_persists_the_root_it_was_handed() { #[test] fn a_room_that_reached_no_sequence_is_dialed_nothing() { - // A rooted room with no ops: an authenticated `Ops` frame carrying no ops roots - // one (C99's shape). No follower holds it — the frame creates no room and there - // is no delta to converge one — so a root sent here would be inert on every dial - // for the life of the room. It has no ACL tuples for a root to decide either. + // A rooted room with no ops. No follower holds it — the frame creates no room and + // there is no delta to converge one — so a root sent here would be inert on every + // dial for the life of the room. It has no ACL tuples for a root to decide either. + // + // No write establishes this state any more: a root is established only over a room + // that has reached a sequence (C99). What still reaches it is a state install + // landing at sequence zero, which leaves the standing root alone rather than + // letting an empty state strip a room's authority. No honest catch-up produces one + // — `Hub::catch_up` yields a `Snapshot` only below the room's floor, so it is sent + // at a floor of at least one — so the producer is a buggy or hostile peer, reaching + // this node's install through a superseding epoch. Driven here through the hub for + // the same reason the state is worth pinning: the guard is about the state, not + // about who put the node in it. let room = room_led_by_a_with_b_next(); let mut leader = node(A); let alice = hello_auth(&mut leader, 1, "t-alice"); assert!(leader.deliver(alice, sub(&room))); leader.take_outbox(alice); - submit(&mut leader, alice, Vec::new()); + let mut doc = Document::new(cid(1)); + submit( + &mut leader, + alice, + doc.transact(|tx| tx.register(OPEN, Scalar::Int(1))), + ); + assert_eq!( + leader.hub().room_creator(&room).as_deref(), + Some(b"alice".as_slice()), + "the write roots the room", + ); + leader + .hub_mut() + .install_snapshot(&room, &Document::new(cid(9)).encode_state(), 0, None) + .expect("the state decodes"); + assert_eq!(leader.hub().seq(&room), 0, "and it now holds no sequence"); assert_eq!( leader.hub().room_creator(&room).as_deref(), Some(b"alice".as_slice()), - "the empty write roots the room", + "with its root standing", ); - assert_eq!(leader.hub().seq(&room), 0, "and lands no sequence"); leader.take_replication(); leader.catch_up_follower(&NodeId::from_addr(B));