fix(server): project a fetched version through the reader's redactions (C15) - #368
Merged
Conversation
…s (C15) `VersionFetch` served `hub.version_state` verbatim, gated only on the deployment-tier room read. A version is the room's own state at an earlier sequence, so a zone-limited subscriber or a doc-ACL partial reader that passes the room gate read the whole room — withheld zones and denied subtrees included — out of any named version. The fetch now runs the two projections a catch-up snapshot runs, in the same order, over the version's own state, with the channel's zone scope and the channel-derived recipient (C9) so an adopted version does not re-mint either. Element-scoped grants resolve against the version's own tree.
…e index A version's bytes come back off durable storage, so an undecodable one is reachable in a way a just-materialized snapshot is not — and unprojectable bytes still carry what a redaction would cut. A reader any redaction could apply to is now refused rather than served them. `zone_narrowing` is the one home of "would the zone projection narrow at all", shared by the projection and that guard. Tests: an element-scoped deny resolving against the version's own tree (the live index leaves it inert once the element leaves the room), the live ACL governing a version captured before the deny, a fetch on a second channel (`for_channel`), and both projections narrowing one version at once.
…use without closing The guard had no reachable trigger through the wire; a store-backed room whose versions file is rewritten with a state that does not decode is one. The refusal also stops closing the connection — one archived state is unreadable, the live stream the channel carries is not.
…action applies A channel's zone set is resolved once at Subscribe against the schema then bound, while the room's acting schema can lift underneath it — so a count that happens to match a later schema's zone count says nothing about which partitions the set names. `zone_narrowing` now asks whether the set holds every declared zone id. The version fetch also stops decoding a state no redaction could apply to (a zoneless, tuple-free room paid a full decode it discarded), and the audit `VersionRead` is recorded where the state actually goes out rather than ahead of a refusal. Files C30 (positional zone ids outliving their schema) and C31 (the catch-up `seq` counts ops in partitions its reader may not see).
… what the guard asks The identity lookup after `version_room` had no reachable failing arm. Resolving it first makes the unauthenticated refusal the one that fires there. The undecodable-version guard asks what a redaction is configured over for the *room*, not what would narrow *this reader* — the per-reader answer needs the element index, the index needs the decode, and the decode is what failed; an empty index answers permissively. So the creator of a room holding tuples is refused too. Prose and a test now say so.
…blob The version fetch had copied the catch-up snapshot arm's read-then-zone sequence verbatim — re-creating, inside the unit that exists to answer it, the shape where a redaction lives at a seam rather than at the state's edge. Both now call `project_served_state`, so a redaction added to it cannot reach one seam and miss the other, and a mutation of it cannot land on a sibling seam and read as covered. Also pins that the fetch narrows by the *channel's* zone scope rather than the actor's entitlement, and corrects the docs: the narrowability question is per-channel, not per-room; the zone scope is the one tier a bound channel resolves once rather than per request; and C30 covers the zoneless-to-zoned lift, where a channel carries no set at all.
vieiralucas
force-pushed
the
server/c15-versionfetch-projection
branch
from
July 29, 2026 00:47
91f5fe2 to
bf4f2c7
Compare
…the order buys A bound channel's zone set can both miss a declared partition and name ids a later schema no longer declares, so neither a member count nor containment alone is safe in both directions. Whole-zone is now the exact declared id range; anything short of it projects. The rustdoc claimed the read-then-zone order was a containment invariant and gave a mechanism that runs backwards — reversing it is *more* redactive, since a zone-purged node leaves no placement for the reveal rule to find. The order is a convergence property (the reveal rule reads the whole tree, in the order the op fan-out resolves in); the prose now says that instead. Also scopes the refuse-rather-than-serve claim to version state, the not-widened-gate claim to the reader it actually excludes, and the cost note to every narrowable fetch.
…re it does not hold `project_served_state`'s rustdoc claimed `index` always belongs to the tree being narrowed. It does at the version fetch, and it does for a `main` catch-up — but not for a branch whose base was forked from a version, where the gate resolves element grants against live main while the state served is the version's bytes. An unresolvable element scope is inert, an inert deny is no deny, and a gate that finds none serves the state whole. Filed as C32, reproduced against `main` too; the rustdoc now names the case instead of denying it. Also: whole-zone is "exactly the declared id range", not "holds every declared id"; skipping the zone projection is a frontier decision, not a no-op; the subscribe path already fails closed on its own durable branch base; and the fetch/fan-out claim says same set rather than same verdict. Tests: a reader denied every declared zone keeps only the root partition; the schema-lift test pins the de-zoned partition it does *not* recover; the creator test asserts the actor really is the creator.
… claims Read-then-zone is the catch-up seam's order, preserved verbatim by the extraction. Reversing it is more redactive, not less, so it is not a containment property — and no test distinguishes the two, so the rustdoc now gives it as the reason the order was chosen rather than as an invariant. Also: the zone projection's fail-open arm notes that the fetch's refusal covers the bytes it was handed, not the read projection's re-encode; a refused fetch still records the composed room Read its gate observed; and C30 gains the third shape of the same cause — a room bound to a schema the registry stores but cannot parse resolves to no schema, so nothing narrows.
vieiralucas
added a commit
that referenced
this pull request
Jul 29, 2026
…vidence actually converges to Cold review pass 8. `dial_establishes_identity` decides what enters `verifiers`, and `verifiers` is what the ring is derived from, so it has to be the same answer everywhere. It read `advertises_tls`, a roster lookup, which answers `false` for a member not yet learned — so the frame that *introduces* a sender was judged differently from every later one, and the claims it carried were kept by nodes that already knew it and dropped by nodes meeting it. One delivery, two states. A node id is an advertise address, so the transport is a property of the id and needs no state to read. The module docs claimed the evidence "converges however gossip interleaves". It does not. Liveness is relayed and anti-entropies; a verification claim is first-hand and is never passed on, deliberately, because with no signature on the wire "A and B verified X" is free for any node to write and relaying it would hand a compromised member a mint rather than a split. The consequence is that a compromised adopted member is a swing vote for any candidate exactly one other trust unit has reached: it sets the flag toward some peers and clears it toward others, and the groups place rooms differently — measured at 721/1000 rooms, 149 of them with two nodes each believing itself primary, surviving 30 further anti-entropy sweeps. The docs now state what holds, and the residual is pinned as a passing test and filed as C39. Board: C15 (#368/#373) filed its own C27-C33 first, so this branch's units are renumbered C34-C38 and the stale duplicate C26 line is dropped.
vieiralucas
added a commit
that referenced
this pull request
Jul 29, 2026
…ic rank, not arrival order (C24) A children-list position is keyed `(list, stamp)` and a document holds at most one, since `read_state` refuses a duplicate. Two ops can carry one stamp into one list and both pass every gate — dedup is on `OpId`, and the id-space record only bounds an honest mint — so which of them takes the key had to stop being a function of delivery order. The rank: a birth over a move, then the smaller element id, then the kind of op that asks, then the anchor it names. A birth outranks because the key is where the born node's id comes from. The birth test reads the key rather than the registry, so a reveal under the other kind cannot invert it. The last two levels separate the claims that name one node, which the id cannot: two moves of it kept the first arrival's anchor and two same-tag inserts the last one's. Neither is remembered — the move log answers which kind put the holder there, the sequence answers where it sits — so a replica that reloaded between the two ops ranks the second as one that stayed up does. A later-arriving winner evicts: the incumbent's placement goes, its move edge is withdrawn by the same undo-and-replay a late arrival uses, its reachability edge is re-derived from the surviving log, and a node left with no placement returns to awaiting its first, which is where a refusal leaves a claimant too. The sequence slot is re-seated rather than re-inserted, and that reaches the tombstones: a delete landing between the two ops would otherwise freeze the loser's anchor into the dead run on one replica and the winner's on the other. The created-under relation is re-seeded from all three of the places it is written — birth placement, map half, and a node holding no placement at all — before the log replay, at every site that rebuilds it. A losing birth keeps that edge live but had none re-derived on decode, so the live replica refused a move under such a node as a cycle and then could not decode its own snapshot, while a replica started from those bytes admitted it and the node it took vanished. The projection re-fold empties the log and replays it as the last step before an encode, so a source missing there shipped a cyclic snapshot in a release build. The map half of the tree now anchors the cycle check, which is the only part of this change that alters an honest document; ARCHITECTURE and DECISIONS record it. The four units C24 filed take C40-C43, the ids C15 (#368) had already merged having been taken.
vieiralucas
added a commit
that referenced
this pull request
Jul 29, 2026
…ic rank, not arrival order (C24) A children-list position is keyed `(list, stamp)` and a document holds at most one, since `read_state` refuses a duplicate. Two ops can carry one stamp into one list and both pass every gate — dedup is on `OpId`, and the id-space record only bounds an honest mint — so which of them takes the key had to stop being a function of delivery order. The key goes to a birth over a move, then to the smaller element id. A birth outranks because the key is where the born node's id comes from. The birth test reads the key rather than the registry, so a reveal under the other kind cannot invert it. A later-arriving winner evicts: the incumbent's placement goes, its move edge is withdrawn by the same undo-and-replay a late arrival uses, its reachability edge is re-derived from the surviving log, and a node left with no placement returns to awaiting its first, which is where a refusal leaves a claimant too. The sequence slot is re-seated rather than re-inserted, and that reaches the tombstones: a delete landing between the two ops would otherwise freeze the loser's anchor into the dead run on one replica and the winner's on the other. That rank orders nodes, which is the whole question only while two claims name two. Two moves of one node, or two inserts carrying one tag, hold one id between them, and the position was falling to arrival order — the first one's anchor for two moves, the last one's for two inserts. There the key is already the claimant's own and nothing changes hands: the sequence re-seats the id at the meet of the two anchors, which cannot show which landed first. A contest would have needed to know what put the incumbent at the key, and nothing answers that — the move log dedups on the stamp alone, so a move of another node at that stamp into another list takes the slot and the key-holder records no edge. The created-under relation is re-seeded before the log replay at both sites that rebuild it. A losing birth keeps that edge live but had none re-derived on decode, so the live replica refused a move under such a node as a cycle and then could not decode its own snapshot, while a replica started from those bytes admitted it and the node it took vanished. The projection re-fold empties the log and replays it as the last step before an encode, so a source missing there shipped a cyclic snapshot in a release build. The relation also runs through the map half one hop at a time, so the check reaches as deep as the nesting rather than one hop over the first map — the only part of this change that alters an honest document, recorded in ARCHITECTURE and DECISIONS. The four units C24 filed take C40-C43, the ids C15 (#368) had already merged having been taken.
vieiralucas
added a commit
that referenced
this pull request
Jul 29, 2026
…ic rank, not arrival order (C24) A children-list position is keyed `(list, stamp)` and a document holds at most one, since `read_state` refuses a duplicate. Two ops can carry one stamp into one list and both pass every gate — dedup is on `OpId`, and the id-space record only bounds an honest mint — so which of them takes the key had to stop being a function of delivery order. The key goes to a birth over a move, then to the smaller element id. A birth outranks because the key is where the born node's id comes from. The birth test reads the key rather than the registry, so a reveal under the other kind cannot invert it. A later-arriving winner evicts: the incumbent's placement goes, its move edge is withdrawn by the same undo-and-replay a late arrival uses, its reachability edge is re-derived from the surviving log, and a node left with no placement returns to awaiting its first, which is where a refusal leaves a claimant too. The sequence slot is re-seated rather than re-inserted, and that reaches the tombstones: a delete landing between the two ops would otherwise freeze the loser's anchor into the dead run on one replica and the winner's on the other. That rank orders nodes, which is the whole question only while two claims name two. Two moves of one node, or two inserts carrying one tag, hold one id between them, and the position was falling to arrival order — the first one's anchor for two moves, the last one's for two inserts. There the key is already the claimant's own and nothing changes hands: the sequence re-seats the id at the meet of the two anchors, which cannot show which landed first. A contest would have needed to know what put the incumbent at the key, and nothing answers that — the move log dedups on the stamp alone, so a move of another node at that stamp into another list takes the slot and the key-holder records no edge. The created-under relation is re-seeded before the log replay at both sites that rebuild it. A losing birth keeps that edge live but had none re-derived on decode, so the live replica refused a move under such a node as a cycle and then could not decode its own snapshot, while a replica started from those bytes admitted it and the node it took vanished. The projection re-fold empties the log and replays it as the last step before an encode, so a source missing there shipped a cyclic snapshot in a release build. The relation also runs through the map half one hop at a time, so the check reaches as deep as the nesting rather than one hop over the first map — the only part of this change that alters an honest document, recorded in ARCHITECTURE and DECISIONS. The four units C24 filed take C40-C43, the ids C15 (#368) had already merged having been taken.
vieiralucas
added a commit
that referenced
this pull request
Jul 29, 2026
…ic rank, not arrival order (C24) A children-list position is keyed `(list, stamp)` and a document holds at most one, since `read_state` refuses a duplicate. Two ops can carry one stamp into one list and both pass every gate — dedup is on `OpId`, and the id-space record only bounds an honest mint — so which of them takes the key had to stop being a function of delivery order. The key goes to a birth over a move, then to the smaller element id. A birth outranks because the key is where the born node's id comes from. The birth test reads the key rather than the registry, so a reveal under the other kind cannot invert it. A later-arriving winner evicts: the incumbent's placement goes, its move edge is withdrawn by the same undo-and-replay a late arrival uses, its reachability edge is re-derived from the surviving log, and a node left with no placement returns to awaiting its first, which is where a refusal leaves a claimant too. The sequence slot is re-seated rather than re-inserted, and that reaches the tombstones: a delete landing between the two ops would otherwise freeze the loser's anchor into the dead run on one replica and the winner's on the other. That rank orders nodes, which is the whole question only while two claims name two. Two moves of one node, or two inserts carrying one tag, hold one id between them, and the position was falling to arrival order — the first one's anchor for two moves, the last one's for two inserts. There the key is already the claimant's own and nothing changes hands: the sequence re-seats the id at the meet of the two anchors, which cannot show which landed first. A contest would have needed to know what put the incumbent at the key, and nothing answers that — the move log dedups on the stamp alone, so a move of another node at that stamp into another list takes the slot and the key-holder records no edge. The created-under relation is re-seeded before the log replay at both sites that rebuild it. A losing birth keeps that edge live but had none re-derived on decode, so the live replica refused a move under such a node as a cycle and then could not decode its own snapshot, while a replica started from those bytes admitted it and the node it took vanished. The projection re-fold empties the log and replays it as the last step before an encode, so a source missing there shipped a cyclic snapshot in a release build. The relation also runs through the map half one hop at a time, so the check reaches as deep as the nesting rather than one hop over the first map — the only part of this change that alters an honest document, recorded in ARCHITECTURE and DECISIONS. The four units C24 filed take C40-C43, the ids C15 (#368) had already merged having been taken.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes C15. Security defect.
Message::VersionFetchanswered withhub.version_stateverbatim, gated only on the deployment/schema-tier roomReadthatversion_roomresolves. Every other seam that hands a client a state blob runs the catch-up projections first —project_read_paths, thenproject_zones. A version is the same bytes at an earlier sequence, so a reader that passed the room gate fetched the whole room out of any named version: withheld zones and doc-ACL-denied subtrees included, plus the full causal frontier naming every other replica's ops in the partitions it may not read.project_served_state, called by the subscribe catch-up arm and the version fetch alike. Duplicating it at the second seam would have re-created, inside the unit that exists to answer it, the shape where a redaction lives at a seam rather than at the state's edge — and a mutation of a duplicated seam can land on the sibling and read as covered.Subscription.zones— the field the live fan-out filters by, so a fetch and a fan-out on one channel cannot disagree. Pinned against a re-derived whole-room scope.for_channelof the id declared at Hello, so a version a reader adopts does not re-mint into ids the room's log already holds.zone_narrowingdecides whole-zone by containment over the declared ids, not by counting members: a channel's zone ids are positions resolved once at Subscribe, the room's governing schema lifts underneath a bound channel, and a member count that matches a later schema's zone count says nothing about which partitions the set names.VersionReadis recorded where the state goes out rather than ahead of a refusal.Gate unchanged:
version_roomis not widened to Subscribe's subtree-scoped admission. Narrowing what a reader is served is this unit; widening who may fetch is not.Spec:
crates/server/tests/version_projection.rs(14) +crates/server/tests/version_unreadable.rs(5, non-Miri: it damages a durable versions file). Zone-limited and doc-ACL-partial readers each fetch a named version and get only their partitions; both narrow one version at once; unnarrowed controls for a whole-zone and a whole-document reader, each also pinning that their frontier is not scrubbed; re-mint and frontier-privacy tests on both axes; a fetch on a second channel; a channel narrower than its actor's entitlement; an element-scoped deny resolving against the version's own tree; the live ACL governing a version captured before the deny; a stale zone set surviving a schema lift; and an undecodable version refused for a zone-limited reader, for a doc-ACL room, and for that room's creator, while a zoneless tuple-free room still serves it.Mutation-swept against an isolated copy of the committed tree under a unit-scoped path, unmutated baseline 0 and every run reporting all 7 suites: dropping either projection,
reads_all = true,recipient: None,session.clientinstead offor_channel, the live element index, a narrowability test that ignores either half, serving an undecodable version,zone_narrowingalways/never narrowing,zone_narrowingback to the cardinality proxy, and a zone scope re-derived whole-room at fetch time — 13/13 killed by named tests.Filed rather than bundled, all reachable under the same gate: C27 (
DiffQueryover two versions or two branches serves unredactedChanges, un-channel-bound), C28 (CloneRoomcopies a room's whole state into an attacker-named room that inherits neither the zone verdicts nor the creator), C29 (a room installed by snapshot has no creator, soreads_whole_documentshort-circuitstrueand a follower serves both seams unredacted), C30 (a channel's positional zone ids outlive the schema they were resolved against — including a channel bound before the room had zones at all, which carries no set and so skips the projection wholesale), C31 (the catch-upseqonSnapshotandVersionStateis the room's global op counter, so it counts ops in partitions its reader may not see).Copilot is quota-limited on this repo and returned "unable to review" on every head, so the external-review leg is unavailable; five independent cold local review passes stood in, each reading the branch fresh. Their HIGH and MEDIUM findings are either fixed here or filed as C27–C31.