Skip to content

fix(ui): structurally prevent re-accept orphaning in the invitation GET handler (#367)#368

Merged
sanity merged 4 commits into
mainfrom
fix/issue-367
Jun 21, 2026
Merged

fix(ui): structurally prevent re-accept orphaning in the invitation GET handler (#367)#368
sanity merged 4 commits into
mainfrom
fix/issue-367

Conversation

@sanity

@sanity sanity commented Jun 21, 2026

Copy link
Copy Markdown
Contributor

Problem

Follow-up to #365 / PR #366. PR #366 fixed the user-visible #365 bug (accepting an invite to a room you're already in joins you twice and orphans the original membership) with an entry-level guard in receive_invitation_modal::accept_invitation. That guard closes every realistic path to the symptom, but it is best-effort: it falls through if ROOMS.try_read() returns Err at accept time.

This PR closes the defense-in-depth gap one layer deeper, in the invitation-accept GET handler (get_response.rs). If the entry-level guard is ever skipped — unreadable ROOMS, or some future caller populating PENDING_INVITES for a room the user is already in — the GET handler still ran the full new-member accept for an already-member room:

A first attempt in PR #366 guarded only the self_sk overwrite. External (Codex) review correctly flagged that as worse: it preserved the old self_sk while the rest of the handler still recorded the new key's credentials and PUT the new member, leaving self_sk and the recorded credentials pointing at different keys — a split, internally inconsistent local state. That partial guard was reverted.

Approach

Detect "already a member under the held self_sk" early — before build_state_for_put — and short-circuit the entire accept:

  • New pure predicate held_key_is_member(owner_vk, members, self_vk) mirrors vk_is_room_member (the accept-time guard's predicate) so both layers agree on what "already a member" means. It checks the held self_sk, never the invitation's fresh invitee_signing_key.
  • At the top of the is_pending_invite branch, synchronously read ROOMS; if the held key is already a member, do a no-op refresh: merge the retrieved state, capture_self_membership_data, repopulate secrets, mark the pending invite Subscribed (so the modal's terminal-success path closes it), and return. No duplicate PUT, no new credentials, no self_sk change.
  • Best-effort in the same sense as the entry-level guard: only fires when ROOMS is readable; otherwise falls through to the normal accept (prior behavior for that rare case). A genuine rejoin after leaving is unaffected — leave_room drops the room from ROOMS, so the held key is no longer a member and the guard does not fire.

This is the structural backstop that makes #365 impossible regardless of how the GET was triggered.

Testing

cargo test -p river-ui --bins — full suite passes (366 tests). New / updated tests:

cargo fmt + cargo clippy -p river-ui --bins clean (no new warnings). UI-only change — no delegate/contract WASM touched, so no migration required.

Review

Risk tier: Full (touches invitation/membership state-authorization logic, the surface of a prior bug). Multi-model review run serially (this PR was produced by a dispatched agent that cannot spawn parallel subagents): external non-Claude model pass (codex review / gemini) plus the four review lenses (code-first, testing, skeptical, big-picture). Findings and outcome posted as a follow-up comment.

Closes #367

[AI-assisted - Claude]

🤖 Generated with Claude Code

sanity and others added 4 commits June 21, 2026 11:26
…andler

Add an early short-circuit at the top of the `is_pending_invite` branch in
`handle_get_response`: when a GET for a pending invite arrives for a room the
user is ALREADY a member of under their held per-room identity
(`RoomData.self_sk`), treat it as a no-op refresh (merge retrieved state,
repopulate secrets, clear the pending invite) instead of running the full
new-member accept.

Without this, a GET that reached the handler with `PENDING_INVITES` populated
for an already-member room (e.g. the accept-time guard in
`receive_invitation_modal::accept_invitation` falling through on an unreadable
`ROOMS`, or a future caller) would PUT a duplicate member, overwrite `self_sk`
with the invitation's fresh key — orphaning the original membership (the #365
mechanism) — and record the fresh key's self-credentials. A partial backstop
guarding only the `self_sk` overwrite was rejected on PR #366 for leaving
`self_sk` and the recorded credentials pointing at different keys; the correct
fix short-circuits the entire accept before `build_state_for_put`.

The short-circuit gates on a new pure `held_key_is_member` predicate that
mirrors `vk_is_room_member` (the accept-time guard's predicate), so both
layers agree on what "already a member" means. It is best-effort in the same
sense: it only fires when `ROOMS` is readable, falling through to the normal
accept otherwise. A genuine rejoin after leaving is unaffected (`leave_room`
drops the room from `ROOMS`).

Tests:
- `held_key_is_member_matches_owner_and_members` — pure predicate unit test
  (owner, member, and non-member/genuine-new-join cases).
- `reaccept_get_short_circuits_before_build_state_for_put` — source-grep pin
  that the short-circuit runs before `build_state_for_put` (the full handler
  reads signals/PUTs over the WebApi, so it can't run under a host unit test).
- Updated `repopulate_secrets_call_sites_pinned` count 5 -> 6 for the new
  refresh path's `repopulate_secrets_from_state` call.

Full `river-ui --bins` suite passes (366 tests); fmt + clippy clean. UI-only,
no delegate/contract WASM touched, so no migration required.

Closes #367

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Codex P2 review finding on PR #368: the re-accept backstop judged
membership from the local ROOMS snapshot, which can be stale. If the user was
pruned for inactivity or removed/banned remotely, local ROOMS may still list
their held key while the freshly-fetched canonical state no longer does — and
a new invitation in that situation is a LEGITIMATE rejoin that must publish the
invitation's fresh key.

Now the short-circuit reads only WHICH key the user holds (`self_sk`) from the
local snapshot and decides membership against the authoritative
`retrieved_state.members`. A held key that is no longer a member canonically
falls through to the normal accept (legitimate rejoin), while an actually-held
membership still short-circuits to a no-op refresh (no duplicate join, no
orphaning).

Extends the `reaccept_get_short_circuits_before_build_state_for_put` source pin
to assert the predicate is applied to `retrieved_state.members`, so a future
refactor can't silently revert to the stale-local-state check.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address two further Codex P2 findings on PR #368:

1. Refresh-only short-circuit left the room stuck in `RoomSyncStatus::Subscribing`.
   `process_rooms()` marks the room `Subscribing` before sending the GET; the
   refresh path neither PUTs nor re-subscribes, so nothing cleared that state
   and the room sat in `Subscribing` until the retry/timeout machinery fired.
   Now set `Subscribed` on the refresh path, mirroring the existing-room
   refresh branch.

2. Genuine-rejoin fall-through could still split local identity. When the held
   key is no longer a member in the canonical state (pruned/banned remotely)
   the guard correctly falls through to the normal accept — but the downstream
   existing-room `self_sk` overwrite was gated on the STALE PRE-MERGE local
   members, so a stale snapshot still listing the old key left `self_sk` at the
   old key while the PUT published the fresh member and the recorded credentials
   pointed at the fresh key. Move the merge BEFORE the membership check so the
   `self_sk` decision is judged against the canonical merged members: a removed
   held key is now correctly replaced by the fresh invitation key (consistent
   rejoin), while an actually-held membership is still preserved (no orphan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Address Codex P2 finding on PR #368: the refresh-only short-circuit set
SYNC_INFO to `Subscribed`, but this code path never establishes a subscription
(the pending-invite GET is sent with `subscribe: false`, and the refresh path
neither PUTs nor subscribes). For a room that is not actually subscribed yet —
a stale pending invite surviving a reload/reconnect, or an imported room —
claiming `Subscribed` would make `rooms_awaiting_subscription` skip it, so the
room would silently stop receiving updates while appearing healthy.

Reset to `Disconnected` instead. That is the status `rooms_awaiting_subscription`
keys on, so `process_rooms`'s existing-room path will establish a genuine
PUT+subscribe when needed; the re-PUT is idempotent and self-healing if the
room was in fact already subscribed. This still gets the room out of the
`Subscribing` state the pending-invite GET left it in (the original finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@sanity

sanity commented Jun 21, 2026

Copy link
Copy Markdown
Contributor Author

Review summary (Full tier — serial path)

This PR touches invitation/membership state-authorization, a high-risk surface and the site of a prior bug (#365), so it is Full tier. It was produced by a dispatched agent that cannot spawn parallel subagents, so reviews were run serially by the author per ~/.claude/rules/multi-model-review.md: an external non-Claude model pass (Codex) plus the four review lenses (code-first, testing, skeptical, big-picture) applied to the checked-out code.

External model pass

  • Codex (codex review --base origin/main, gpt-5.x): ran successfully across 4 rounds (re-run after each fix, since a review is per-code-content). Final round: clean — "I did not find a discrete correctness issue introduced by the diff."
  • Gemini (gemini-cli): unavailableIneligibleTierError: This client is no longer supported for Gemini Code Assist for individuals (free-tier client deprecated). Not a quota/outage I can wait out; Codex provided the independent non-Claude signal, so no Claude-lens fallback was needed (fallback is only when ALL external models are down).

Codex findings — all addressed

  1. [P2] Judge membership against canonical fetched state, not the local snapshot. The first cut gated the short-circuit on local ROOMS, which can be stale (member pruned/banned remotely). Fixed: the short-circuit now reads only which key the user holds from local ROOMS and decides membership against the authoritative retrieved_state.members. A held key that is no longer a member canonically falls through to a genuine rejoin. (commit 27de9ec5)
  2. [P2] Genuine-rejoin fall-through could split local identity. The downstream existing-room self_sk overwrite was gated on the pre-merge (stale) local members, so a removed held key could leave self_sk at the old key while the PUT published the fresh member and credentials pointed at the fresh key. Fixed: the merge now runs before the membership check, so the self_sk decision sees canonical merged members — removed key → adopt fresh key (consistent rejoin); held membership → preserved (no orphan). (commit 78fe5abf)
  3. [P2] Refresh path sync-status bookkeeping. Round-2 set SYNC_INFO Subscribed, which would mask a not-actually-subscribed room (stale invite after reload, imported room) and stop it receiving updates. Fixed: reset to Disconnected — the status rooms_awaiting_subscription keys on — so process_rooms establishes a real PUT+subscribe if needed (idempotent/self-healing if already subscribed), while still clearing the Subscribing state the pending-invite GET left behind. (commit c3c2c566)

Four-lens read (author)

Local verification

cargo fmt clean; cargo clippy -p river-ui --bins introduces no new warnings (13 pre-existing, none in get_response.rs); cargo test -p river-ui --bins 366 passed (matching CI's gate). UI-only — no delegate/contract WASM touched, no migration.

[AI-assisted - Claude]

@sanity sanity merged commit f00f3b7 into main Jun 21, 2026
6 checks passed
@sanity sanity deleted the fix/issue-367 branch June 21, 2026 16:59
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.

River UI: structurally prevent re-accept orphaning in the invitation GET handler (follow-up to #365)

1 participant