Skip to content

fix(server): project a fetched version through the reader's redactions (C15) - #368

Merged
vieiralucas merged 14 commits into
mainfrom
server/c15-versionfetch-projection
Jul 29, 2026
Merged

fix(server): project a fetched version through the reader's redactions (C15)#368
vieiralucas merged 14 commits into
mainfrom
server/c15-versionfetch-projection

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Jul 28, 2026

Copy link
Copy Markdown
Member

Closes C15. Security defect. Message::VersionFetch answered with hub.version_state verbatim, gated only on the deployment/schema-tier room Read that version_room resolves. Every other seam that hands a client a state blob runs the catch-up projections first — project_read_paths, then project_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.

  • One composition, not two copies. The read-then-zone sequence now lives in 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.
  • Zone scope is the channel's own 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.
  • Recipient threaded through (C9): for_channel of the id declared at Hello, so a version a reader adopts does not re-mint into ids the room's log already holds.
  • Element-scoped grants resolve against the version's tree, not the live room. Live records still govern — authorization is a decision about now — but an element's redaction path is where it stood at capture. Resolving against the live room leaves the deny inert the moment the element leaves it, and an inert deny is not a narrower read, it is the whole version.
  • zone_narrowing decides 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.
  • An unprojectable version is refused, not served (non-closing — one archived state is unreadable, the live stream the channel carries is not). Version bytes come back off durable storage, so an undecodable one is reachable in a way a just-materialized snapshot is not. The guard asks what a redaction is configured over for these bytes on this channel, not what would actually have been cut: that answer needs the element index, the index needs the decode, and the decode is what failed. The fetch decodes only when a redaction could apply at all, and the audit VersionRead is recorded where the state goes out rather than ahead of a refusal.

Gate unchanged: version_room is 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.client instead of for_channel, the live element index, a narrowability test that ignores either half, serving an undecodable version, zone_narrowing always/never narrowing, zone_narrowing back 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 (DiffQuery over two versions or two branches serves unredacted Changes, un-channel-bound), C28 (CloneRoom copies 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, so reads_whole_document short-circuits true and 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-up seq on Snapshot and VersionState is 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.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:25

This comment was marked as outdated.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:27

This comment was marked as outdated.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:40

This comment was marked as outdated.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 22:49

This comment was marked as outdated.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 23:08

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vieiralucas
vieiralucas requested a review from Copilot July 28, 2026 23:43

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@vieiralucas
vieiralucas requested a review from Copilot July 29, 2026 00:21

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

…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
vieiralucas force-pushed the server/c15-versionfetch-projection branch from 91f5fe2 to bf4f2c7 Compare July 29, 2026 00:47
…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
vieiralucas merged commit 0ad8061 into main Jul 29, 2026
18 checks passed
@vieiralucas
vieiralucas deleted the server/c15-versionfetch-projection branch July 29, 2026 03:33
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants