Skip to content

fix(server): replicate a room's creator, so a replica holds its authority root (C29) - #374

Open
vieiralucas wants to merge 6 commits into
mainfrom
server/c29-replicated-creator
Open

fix(server): replicate a room's creator, so a replica holds its authority root (C29)#374
vieiralucas wants to merge 6 commits into
mainfrom
server/c29-replicated-creator

Conversation

@vieiralucas

@vieiralucas vieiralucas commented Jul 29, 2026

Copy link
Copy Markdown
Member

A room a node holds only through replication had no creator, so it had no doc-ACL authority root: reads_whole_document short-circuits true on a creatorless room and doc_acl_read_at abstains, while acl_records is non-empty because ACL ops ride the log like any other. Reproduced against main before the fix.

  • Read leak. A partial reader whose read landed on a caught-up follower was served the whole document — the op catch-up, the snapshot catch-up (project_served_state), and VersionFetch alike. Not a narrowing error; the redaction was skipped wholesale.
  • Escalation. Failover makes a replica a leader, a leader takes client writes, so ensure_creator finally fires there and the next actor to write took /.
  • Message::Replicate and Message::ReplicateSnapshot carry creator: 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.
  • Follower adopts it set-once — ensure_creator on the ops path, install_snapshot on the state-transfer path. install_room_state composes standing.or(incoming), so a re-sent frame never re-roots a replica and one naming none never un-roots it.
  • install_room_state persists the room's metadata, and therefore carries the op-version high-water across the replace: writing the post-install None into that record would have put a lower all-time high-water on disk than the one already there.
  • Wire: an optional byte string, ahead of Replicate's remainder-consuming op batch.
  • Only an actor that could come back is admitted, wherever it arrives from: the write path's anonymous-writer refusal moves into Hub::ensure_creator and 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_room still installs none — portable snapshot bytes name no creator. The laundering that follows is C28.
  • C34 — nothing carries the op-version high-water to a brand-new replica either, so the handshake range-check is inert there.
  • C36 — set-once is hijack-stable and unrepairable: a replica rooted wrongly (a member forging an epoch bump, or two nodes independently rooting a room that was creatorless when it replicated) can never be corrected. Letting a strictly-higher epoch replace the root buys repair at the cost of making the authority root follow the epoch race; the ruling belongs with the real election. Stated in DECISIONS rather than settled here.
  • C35ensure_creator fires 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 empty Replicate would leave a follower holding an empty room reported as servable, which catch_up_room_frame already 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 in persistence.rs; high-water preserved across an install in snapshot_transfer.rs.

ARCHITECTURE's Owner bullet now says where the creator binding lives; DECISIONS 2026-07-29.

…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.
@vieiralucas
vieiralucas requested a review from Copilot July 29, 2026 05: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.

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.
@vieiralucas
vieiralucas requested a review from Copilot July 29, 2026 05:36

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 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.
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