Skip to content

feat: room alias management (view/add/remove/set-main + publish auto-advertise) - #272

Open
ZhangHanDong wants to merge 11 commits into
mainfrom
feat/room-aliases
Open

feat: room alias management (view/add/remove/set-main + publish auto-advertise)#272
ZhangHanDong wants to merge 11 commits into
mainfrom
feat/room-aliases

Conversation

@ZhangHanDong

Copy link
Copy Markdown

Implements room alias management in room settings (closes #266).

What's included

Alias display & management (room settings modal)

  • Canonical (main) + all alt aliases shown as a dynamic PortalList of AliasRow items — every alias is an actionable row (Main badge on canonical), no row cap.
  • Per-row Remove and Set-as-main buttons, value-carrying actions (no index bookkeeping), gated by the room's power-level (can_manage); read-only display otherwise.
  • Add-address flow with client-side validation (bare localparts completed against the homeserver; invalid inputs rejected with localized errors).

Publish → auto-advertise

  • Publishing an alias registers it in the directory and then advertises it into m.room.canonical_alias.alt_aliases — sequenced writes: the state write only runs after directory success.
  • Optimistic UI with snapshot rollback + server-error toast on failure; authoritative FetchRoomSettings reconcile after every attempted write.
  • Idempotent retry repair for partial failures: create_alias 409 treated as success iff the alias already maps to this room; delete_alias 404 treated as success — a user retry heals directory/state divergence.

Write serialization

  • AliasWriteGate (Idle → AwaitingResult → AwaitingRefresh) + app-level per-room PendingAliasWrites registry: at most one in-flight alias mutation per room, surviving modal close/switch/reopen; controls locked until the write's own reconcile lands.
  • Room-scoped guards on fetched settings (stale other-room responses dropped); every fetch answers exactly once (Fetched or FetchUnavailable) 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%.
  • Reviewed through 2 independent review gates across 5 artifact revisions (checksummed patches); all findings from both reviewers fixed except the one known issue below.

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)

  • macOS device pass: alias section layout (PortalList rows, badge, overflow scroll), live publish/remove/set-main round-trips against a homeserver, optimistic → rollback behavior, error toasts, permission gating.

🤖 Generated with Claude Code

ZhangHanDong and others added 11 commits July 23, 2026 02:36
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>
@ZhangHanDong

Copy link
Copy Markdown
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
ZhangHanDong marked this pull request as ready for review July 26, 2026 18:32
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.

Feature: Room aliases — view & manage room aliases from Room Settings

1 participant