feat: room alias management (view/add/remove/set-main + publish auto-advertise) - #272
Open
ZhangHanDong wants to merge 11 commits into
Open
feat: room alias management (view/add/remove/set-main + publish auto-advertise)#272ZhangHanDong wants to merge 11 commits into
ZhangHanDong wants to merge 11 commits into
Conversation
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Pure, unit-tested core for the Room Aliases feature: - normalize_and_validate_alias: parses #localpart:server, completes a bare localpart against the homeserver, rejects empty/whitespace/empty-localpart. - reconcile_canonical_alias: maintains m.room.canonical_alias invariants (promote/demote, reject-unpublished, dedup, remove-clears-canonical). - 7 unit tests (all passing). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Enable the RoomCanonicalAlias power bit and add
UserPowerLevels::can_set_canonical_alias() (via user_can_send_state).
- Add MatrixRequest::{PublishRoomAlias, RemoveRoomAlias, SetRoomCanonicalAlias}
and their worker handlers:
* Publish/Remove -> ruma create_alias / delete_alias directory endpoints.
* SetCanonical -> m.room.canonical_alias state event.
All dispatched via submit_async_request; failures surface a popup.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Replace the AddLocalAddress 'coming soon' stub with a real flow: validate input via normalize_and_validate_alias (homeserver from the signed-in user), then submit MatrixRequest::PublishRoomAlias; invalid input shows a format hint. - Stop stripping the leading '#' in the modal so full aliases validate. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- Add 10 room_settings.aliases.* i18n keys to en / zh-CN dictionaries and test_room_aliases_i18n_keys_exist_in_all_locales, completing the 8th agent-spec Completion Criteria scenario (7 logic + 1 i18n now covered). - Extend RoomSettingsFetchedAction with canonical_alias, alt_aliases and can_manage_aliases, populated in FetchRoomSettings via room.canonical_alias() / room.alt_aliases() and UserPowerLevels::from_room().can_set_canonical_alias(). - RoomSettingsModal::apply_alias_settings renders the canonical + alt alias list with localized labels and gates the add-address control behind the m.room.canonical_alias power level (read-only hint otherwise). Full lib suite: 548 passed, 0 failed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
… with serialized writes Per-row Remove / Set-as-main buttons (PortalList of AliasRow items, value- carrying actions), publish auto-advertise into m.room.canonical_alias alt_aliases with optimistic UI + snapshot rollback, sequenced directory-then- state writes with idempotent retry repair (409-iff-ours / 404-as-success), AliasWriteGate + app-level per-room PendingAliasWrites registry serializing one in-flight alias mutation per room across modal reopen, room-scoped fetch guards, and i18n for all alias UI strings (en + zh-CN). Known issue (from final review, fix follows on this PR): settings fetches carry no request identity/generation, so a stale open-fetch returning after a write enters reconcile can be misread as that write's reconcile and restore a stale snapshot (narrow timing window). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…own reconcile releases its gate Settings fetches carried only room_id, so a stale or unrelated fetch could be mistaken for a specific alias write's reconcile — releasing the gate / clearing the pending registry early and repainting stale full-state (codex P1, msg_0201). Every FetchRoomSettings now carries a RoomSettingsFetchReason (Open vs AliasReconcile(generation)), echoed through both RoomSettingsFetchedAction and RoomSettingsFetchUnavailableAction: - Each mutation takes a monotonic generation (modal-owned); the gate becomes AwaitingResult(gen)/AwaitingRefresh(gen) and the registry stores that generation. - A fetch releases the gate / clears the registry ONLY on AliasReconcile with a matching generation while AwaitingRefresh(gen)/AwaitingReconcile. An open-fetch never consumes a write reconcile, and a mismatched generation is ignored — independent of arrival timing. - apply_alias_settings routes the disposition (Ignore / Apply / ApplyAndRelease) before touching state, so a stale fetch can't clobber optimistic state or the rollback snapshot. release_alias_lock (unavailable path) releases only on the matching reconcile. - Registry and gate stay in lockstep for same-Actions-batch ordering (App-then- UI) because both decide by generation+purpose, not order. - show() reopens a pending room in the matching gate state carrying the original generation, and keeps the local generation source ahead of it. Adds 2 codex-named regression tests (two open-fetches where the stale one returns after the write result; write result + stale fetch in one batch), plus generation-aware gate/registry unit tests. cargo test --lib: 569 passed / 0 failed. agent-spec lint: 100%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ns + server-truth reconciles) Codex found two freshness holes in the generation-tagged fetches (msg_0237), both letting a stale fetch clobber a completed write. P1-1 — Open responses need freshness too. `(Idle, Open) → Apply` re-opened the door: after a write's reconcile released to Idle, an older Open (snapshot taken pre-write) returning last was applied and repainted pre-write state, letting the user submit a second write from it. Every fetch now carries a monotonic epoch (new `OpenFreshness`): an Open applies only if it is still the newest, and BOTH a new write AND any accepted apply push `min_acceptable` past every epoch issued so far — so an older Open can never become acceptable again, even after the gate returns to Idle. Same guard covers the Unavailable-release path (the write's submit already invalidates earlier Opens). P1-2 — reconcile must carry SERVER truth, not local cache. In the pinned matrix-sdk `send_state_event` does not update local RoomInfo, so `room.canonical_alias()/alt_aliases()` can still return the pre-write value; the handler also captured alias data before its power-level await. A reconcile now reads `m.room.canonical_alias` directly from the server (ruma `get_state_event_for_key` via `client.send`) at post time; if that fresh read is unavailable it releases via `RoomSettingsFetchUnavailableAction` instead of applying stale cache data (`reconcile_fetch_outcome`). Opens keep the cache read (epoch-guarded, no extra round-trip). Adds codex-named regressions: write-then-old-Open rejected in BOTH the Fetched and Unavailable variants; reconcile applies server truth and releases-without- applying when the fresh read is unavailable; plus OpenFreshness unit tests. cargo test --lib: 575 passed / 0 failed. agent-spec lint: 100%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…rier + sound reopen recovery Codex found one uncovered interleaving in the freshness model (msg_0246): a reopen DURING a pending write issues Open(e2) with e2 > min_acceptable. If the write's reconcile hits a transient failure (matching Unavailable), release_alias_lock() only set the gate Idle — it did NOT advance OpenFreshness. Open(e2) then applied a possibly pre-write cache snapshot (+ can_manage), letting a second write clobber the first (which had succeeded server-side). The opposite order left the modal wedged read-only. Fixes (codex's required closure): 1. Terminal barrier: a matching-Unavailable reconcile now invalidates every Open issued during that pending generation via OpenFreshness::on_apply (exactly as a successful reconcile does), so a late reopen Open can never apply. 2. Safe recovery: release_alias_lock returns a fresh post-barrier epoch; app.rs issues a new Recovery(epoch) fetch that reads SERVER truth (not the cache) so the reopened modal repopulates state + permission via a fetch postdating the invalidation — never a pre-write snapshot, and never wedged read-only. A new `Recovery` fetch reason behaves like Open in the gate (applies when idle, epoch-guarded) but reads the server like a reconcile; a failed Recovery does not spawn another (bounded). Plain Opens keep the fast cache read — a stale reopen Open is rejected by the barrier, so it never applies. Adds codex-named pure interleaving tests, both orders: matching-Unavailable then post-write Open (rejected); post-write Open then matching-Unavailable (no stale apply AND the post-barrier recovery repopulates, not wedged). cargo test --lib: 577 passed / 0 failed. agent-spec lint: 100%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…erver, cache read deleted Codex's root-cause finding (msg_0255): epoch ordering ranked freshness across TWO sources (local cache vs server) with different consistency, so a later cache-Open could legitimately supersede server truth by epoch and apply a stale snapshot + can_manage — write-enablement (a W2 from the stale full-state snapshot de-advertises a still-directory-mapped alias). Fix (coordinator directive — kill the cache read entirely): EVERY alias fetch (Open, the post-barrier recovery Open, and AliasReconcile) now reads `m.room.canonical_alias` from the SERVER via `fetch_canonical_alias_from_server` + `reconcile_fetch_outcome` (404 → fresh empty; error → Unavailable, no apply). The Open arm's `room.canonical_alias()/alt_aliases()` cache reads are gone. With one source, epochs order same-source reads only (sound), and the entire cross-source provenance-ranking class dies by construction — a fetch payload can never be a stale cache value. The `Recovery` fetch reason collapsed into a plain server-truth `Open` (removed). The barrier + one-shot recovery behavior is unchanged: a matching-Unavailable still invalidates the pending generation's Opens and issues one fresh recovery Open; a failed Open releases nothing (gate untouched) and does not respawn, so there is no wedge and no loop. Modal opens are user-initiated and rare, so the one extra GET per open is negligible. Adds codex-named source-aware tests (a) later-Open-after-recovery-pending and (b) Open-after-recovery-applied-before-sync, proving the applied payload is the server read and a modeled stale cache value can never appear. cargo test --lib: 579 passed / 0 failed. agent-spec lint: 100%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…e until recovery Open applies Codex found the last recovery-path gap (msg_0275): release_alias_lock set the gate Idle and re-rendered IMMEDIATELY on a matching reconcile-Unavailable, with can_manage_aliases still true — so edit controls came back while the recovery Open's server GET was still pending. A write's send can err ambiguously (the server may have applied S1; error != not-applied); the rollback shows S0, so the user could submit W2 from S0 (whose take_write even invalidates the pending recovery epoch) and overwrite S1, losing W1 — violating the gate invariant that the next mutation builds on reconciled state. Fix: a matching-Unavailable no longer unlocks. It enters a new explicit read-only state, AliasWriteGate::Recovering(epoch), where can_submit() is false so mutations are refused and the controls are hidden (edit_enabled = can_manage && can_submit). Only the recovery Open(epoch), when ACCEPTED, applies server truth AND releases the gate to Idle — restoring permission from that apply (which recomputes can_manage from power levels). The barrier (OpenFreshness::on_apply) still fires. A failed recovery Open does not release from the unknown state; the gate stays Recovering (read-only) until an explicit reopen, which re-derives from the (cleared) registry to Idle and issues its own fresh Open — "reopen replaces recovery" (documented in show()). The epoch freshness check now runs before any gate mutation, so a stale open can never release the gate. Adds codex-named tests: (a) Unavailable → recovery pending → second write rejected (can_submit false in Recovering), released only after the recovery Open applies; (b) bounded recovery failure → stays Recovering (read-only), reopen recovers. cargo test --lib: 581 passed / 0 failed. agent-spec lint: 100%. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Author
|
Review-hardening complete: 5 commits (7249d92..6d2d1d2) close the freshness race documented in the description — generation-tagged fetches, epoch-tagged Opens, terminal Unavailable barrier + bounded recovery, single server source of truth, and a read-only Recovering state until recovery applies. 9 dual-gate review rounds; cargo test --lib 581 passed / 0 failed / 4 ignored (pre-existing, unrelated); agent-spec parse OK; lint Quality 100%. Remaining: macOS device validation per the checklist above, then draft → ready for review. 🤖 Generated with Claude Code |
ZhangHanDong
marked this pull request as ready for review
July 26, 2026 18:32
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.
Implements room alias management in room settings (closes #266).
What's included
Alias display & management (room settings modal)
PortalListofAliasRowitems — every alias is an actionable row (Main badge on canonical), no row cap.can_manage); read-only display otherwise.Publish → auto-advertise
m.room.canonical_alias.alt_aliases— sequenced writes: the state write only runs after directory success.FetchRoomSettingsreconcile after every attempted write.create_alias409 treated as success iff the alias already maps to this room;delete_alias404 treated as success — a user retry heals directory/state divergence.Write serialization
AliasWriteGate(Idle → AwaitingResult → AwaitingRefresh) + app-level per-roomPendingAliasWritesregistry: at most one in-flight alias mutation per room, surviving modal close/switch/reopen; controls locked until the write's own reconcile lands.FetchedorFetchUnavailable) so the gate can never strand.i18n: all alias UI strings localized (en + zh-CN), covered by test.
Verification
cargo test --lib: 566 passed / 0 failed / 4 ignored (the 4 are pre-existing, unrelated, documented in issues/011). Includes 8 spec-bound scenarios (specs/task-room-aliases.spec.md) + 20 new pure-logic tests for validation, reconcile, gate, registry, and idempotent-repair decisions.agent-spec parse/lint --min-score 0.7: pass, Quality 100%.Known issue (fix incoming on this PR)
Settings fetches carry no request identity/write generation, so a stale open-fetch (e.g. from a modal open racing a write) that returns after a write enters its reconcile phase can be misread as that write's reconcile: it clears the pending registry, unlocks controls, and repaints a stale snapshot, allowing a follow-up write built on stale state. Narrow timing window (requires a slow fetch spanning a complete write round-trip); shipped as draft per operator decision, fix (generation-tagged fetches + matched reconcile acceptance + regression tests) lands as a follow-up commit on this branch.
Remaining validation (operator)
🤖 Generated with Claude Code