fix(server): replicate a room's creator, so a replica holds its authority root (C29) - #374
Open
vieiralucas wants to merge 6 commits into
Open
fix(server): replicate a room's creator, so a replica holds its authority root (C29)#374vieiralucas wants to merge 6 commits into
vieiralucas wants to merge 6 commits into
Conversation
…rity root (C29) `ensure_creator` fired only on the leader's client ops-write path. A node holding a room purely through replication had no creator, so the room had no doc-ACL authority root: `reads_whole_document` short-circuits `true` and `doc_acl_read_at` abstains, while `acl_records` is non-empty because ACL ops ride the log. A partial reader served by that replica got the whole document — through the op catch-up, the snapshot catch-up, and the version fetch alike — and the first actor to write it after a promotion took `/`. `Message::Replicate` and `Message::ReplicateSnapshot` now carry the creator; a follower adopts it set-once through `ensure_creator` on the ops path and through `install_snapshot` on the state-transfer path. `install_room_state` composes it set-once and persists the room's metadata, so a re-sent frame never re-roots or un-roots a replica and a restart keeps the root. The install also carries the room's op-version high-water across the replace, since it now writes the record that holds it. `import_room` still installs none: portable snapshot bytes name no creator.
The replication spec's frame equality pinned every other field; the creator is the authenticated writer of the first commit, and the same frame that carries that commit carries the authority root under it.
…a frame The write path refused to root a room on an anonymous id — ephemeral per connection, so it could never re-present to exercise the ownership, and set-once would wedge the room's authority on it. A root now also arrives from a peer's frame, so the rule moves into `Hub::ensure_creator` and the install composition as one predicate: non-empty and credentialed. Nothing retries an authority root, so a root that cannot re-present is unrepairable wherever it came from. `catch_up_room_frame` reads the root after the catch-up, as the steady path reads it after the write. Tests: set-once through both wire frames rather than only the hub call, an anonymous and an empty actor rooting nothing, and a rootless frame pinned as leaving the replica rootless. The promotion test now asserts positively that the write landed. Records the set-once trade in DECISIONS and files C36 for the ruling: a replica rooted wrongly can never be corrected.
…s it Refusing to root is not fail-closed: a room with no authority root reads every doc-ACL deny in it as inert, which is the hole this unit closes. So the extra empty-actor clause is dropped — an empty actor carries no anonymous prefix, so every other tier already treats it as authenticated, and making this one seam disagree would strip a room's authority rather than protect it. The rule is exactly `acl::is_authenticated`, applied at `ensure_creator`, at the install composition, and now at the durable load, whose bytes come from outside the process. The write path's own copy of the rule goes with it, so the predicate has the one home its comment claims. `catch_up_room_frame`'s ops arm was unpinned — mutating its creator to `None` left the suite green — while it is the frame the late-joiner dial, the wiped-follower self-heal and the post-drop redial all converge a follower through. Tested, along with a room born in a single commit, which is what pins that the frame is built after the write that establishes the root. Records why the empty actor is admitted, and that a root whose persist failed does not come back on a replica (C35's gap by a second route).
…ed root `a_second_frame_naming_another_root_does_not_displace_the_first` asserted only that the root was unchanged, which a *fenced* frame satisfies too — the gate answers `true` for one, so `deliver` returning true was no evidence. Both frames now show they landed before the absence is read. The durable-load guard had no test: nothing in-process can write an anonymous root, so only a hand-built store reaches it — which is the case its comment names. And the empty actor's admission was prose-only; it is a rule now, so it is pinned. Also: the wire doc claimed every frame for a room names the same root, which is false before one exists; `apply_replicate`'s says where the root comes from rather than what would otherwise happen; the dialed-catch-up test stops claiming its frame is the only one those paths use; and the write path stops allocating the actor on every main-branch write.
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.
A room a node holds only through replication had no creator, so it had no doc-ACL authority root:
reads_whole_documentshort-circuitstrueon a creatorless room anddoc_acl_read_atabstains, whileacl_recordsis non-empty because ACL ops ride the log like any other. Reproduced againstmainbefore the fix.project_served_state), andVersionFetchalike. Not a narrowing error; the redaction was skipped wholesale.ensure_creatorfinally fires there and the next actor to write took/.Message::ReplicateandMessage::ReplicateSnapshotcarrycreator: Option<Vec<u8>>. Both, not just the snapshot: a room born on the leader reaches its followers as an ops delta and may never take a state transfer. Set-once and immutable, so re-sending it costs an actor id per frame and cannot conflict.ensure_creatoron the ops path,install_snapshoton the state-transfer path.install_room_statecomposesstanding.or(incoming), so a re-sent frame never re-roots a replica and one naming none never un-roots it.install_room_statepersists the room's metadata, and therefore carries the op-version high-water across the replace: writing the post-installNoneinto that record would have put a lower all-time high-water on disk than the one already there.Replicate's remainder-consuming op batch.Hub::ensure_creatorand the install composition as one predicate (non-empty and credentialed). Nothing retries an authority root, so one that cannot re-present would wedge the room unrepairably.Not in scope, filed instead:
import_roomstill installs none — portable snapshot bytes name no creator. The laundering that follows is C28.ensure_creatorfires on a dedup no-op while replication is enqueued only for a non-empty broadcast, so a root established by a pure resend reaches no replica until the room's next fresh commit. Closing it needs a signal out of the session layer and a way to carry a root with no op batch (an emptyReplicatewould leave a follower holding an empty room reported as servable, whichcatch_up_room_framealready declines to do).Tests:
replicated_creator.rs(leader control, creator carried by each path, each of the three seams redacting on the replica, a promoted replica keeping its root, set-once against a frame naming another root and one naming none); the replication spec's frame equality now pins the creator; creator round-trips on both frames with the truncation sweeps widened over the field; installed-creator restart inpersistence.rs; high-water preserved across an install insnapshot_transfer.rs.ARCHITECTURE's Owner bullet now says where the creator binding lives; DECISIONS 2026-07-29.