fix(rooms): a room is addressed by its ID — kill name-as-identity end to end - #1361
fix(rooms): a room is addressed by its ID — kill name-as-identity end to end#1361joelteply wants to merge 15 commits into
Conversation
`Room::from_name` derived BOTH the identity (`new_v5(ROOM_NAMESPACE, name)`) and the storage location (`wires/sanitise_name(name)`) from a human string. That one derivation made text the identity, and it is the root of the room defects this crate carries: - an id-SHAPED name hashes into a brand-new channel, so a scope writes and reads a room nobody else is in. Twice in production (c409eaf5): a full uuid, then the 8-hex short id minting ghost room 7d1a76de off academy's prefix. `resolve_id_token` and the JoinUuidString / JoinIdUnknown / JoinIdAmbiguous errors exist ONLY to referee that. - an id can DIVERGE from its stored name, so join carries self-healing (`rebind_diverged`) to re-bind rooms whose uuid no longer derives. - the name charset becomes load-bearing on identity, so a room cannot be called anything a hash-input validator dislikes. `Room::mint` generates the id (v4) and keys the wire dir by that id, so `sanitise_name` has no say in where a room's bytes live. Nothing derives => nothing diverges; the id IS an id => nothing is merely id-shaped; the name is free text => no charset gate on identity. Renaming becomes a label edit and two rooms may share a label. Creation is airc's alone — callers receive an id, they never supply one. `legacy_from_name` is retained for MIGRATION ONLY: rooms created before this have ids that ARE v5(name) with bytes under `wires/<name>`, and existing installs must still find them. `default_for` deliberately stays on it — every scope that has run `init` already has that room. Rendezvous (two machines independently landing on the same room with no registry) was the one thing derivation bought; that is discovery, not identity — peers exchange the id, and named lookup lives at the CLI edge. Foundation only: callers still route through the name-taking join, and that migration is the next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… name The subscription set — the durable record of which rooms this scope belongs to — was `BTreeMap<ChannelName, Subscription>`, with `default` and `parted` also keyed by name. A display string was the lookup key for membership, which meant: - a room could only be remembered if it had a NAME, so a room you were handed (dispatched into, told about by a peer) had an id and nowhere to live in the map; - a row whose stored name no longer parsed was DROPPED on load — a display label could evict a real membership; - the parted set re-DERIVED each room's id from its name against an `unset` identity on every save, fabricating an id that matched the real room only by luck of the derivation. Now keyed by `RoomId`. No data migration is needed and that is the telling part: `StoredSubscription` has always persisted `room_id` alongside the name, so the address was on disk the whole time and only the in-memory key was wrong. Load keys off `row.room_id`; an unparseable label degrades to `ChannelName::unnamed()` and the membership survives, because the id identifies it. Parted rows carry their real id and an empty label. `RoomId` gains `Ord`/`PartialOrd` so an id can key an ordered collection at all — without it the only sortable handle on a room was its name, which is how the label became the key in the first place. Keys are now a 16-byte `Copy` uuid rather than a heap `String`: no allocation per lookup, no hashing a name to find a room. WIP: 25 call sites still pass a `ChannelName` where a `RoomId` now belongs — the compiler is enumerating exactly where a name was standing in for an address. Committed at this point so the structural change is not lost mid-conversion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ved from text A room is its RoomId: a v4 uuid minted when the room is created, read-only to every caller, and the only thing that addresses it. A ChannelName is a display label that keys nothing — two rooms may share one, a room may have none, renaming moves nothing. What this deletes, and why each existed only to referee a derived id: - Room::legacy_from_name / ROOM_NAMESPACE / sanitise_name — `new_v5(namespace, name)` made text the identity, and `wires/<sanitised-name>` made text the storage location. Room::mint is now the only constructor; the wire dir is keyed by the id. - subscriptions::derive_room_id + SUBSCRIPTIONS_NAMESPACE — same derivation one layer up. Subscription::minting mints; Subscription::joining takes an id it was GIVEN. - SubscriptionSet::rebind_diverged + SubscriptionRebind + warn_subscription_rebinds — self-healing for ids that DIVERGED from their name. Nothing derives, so nothing can diverge. - RouterInboundBridge::reconverge_by_name — remapped an inbound frame's delivery room from its sender-supplied NAME header. A peer could redirect a frame by sending different text. Deleted: the frame's room id is where it goes, full stop. - the conjured `#general` in subscribed_room_ids — a scope with no subscriptions is in no rooms; inventing an id there had it draining a room nobody put it in. Re-keyed, ids all the way down: - SubscriptionSet.subscribed: BTreeMap<RoomId, Subscription>, default/parted by id. No migration: StoredSubscription has always persisted room_id, so the id was on disk the whole time; only the in-memory key was wrong. A row whose label no longer parses is now a perfectly good membership instead of a dropped one. - PresenceBeacon.subscribed_rooms / CoordinatorSnapshot.live_rooms / the account registry document: Vec<RoomId>. A beacon advertising a label tells a peer nothing it can deliver on. - unsubscribe / set_default / is_subscribed / subscription_cursor take a RoomId. SubscriptionError::UnknownRoom reports the id verbatim — there is no name to guess. Name lookup survives only at the CLI edge, where a human types `#general`: it scans the labels of rooms this scope is already in, and REFUSES when a label matches more than one — a label is not an address, so picking for the caller would be a guess. `airc join <token>` resolves an id (full or 8-hex short form) against rooms it holds, matches a label, or mints; an id-shaped token that matches nothing is still a loud error, never a fresh room. Whole airc workspace compiles. Test mods still reference the deleted derivation and are the next commit — this one is the source change, stated plainly. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…e derivation room/tests.rs replaced wholesale — its three tests existed only to pin the derivation (`from_name` deterministic across homes, differs per name, and `sanitise_name`). Every one of those properties is now a defect. The replacements pin the opposite: - mint gives every room its own id, and two rooms MAY share a label - ids are v4 (a v5 here would mean something hashed an input into an identity) - the wire dir is keyed by the id, so `../etc/passwd` as a label reaches no path - renaming touches one field: id and wire unchanged subscriptions tests re-pointed at the id key: - create mints distinctly and seeds default without stealing it - join is idempotent on the ID and never lets a caller's label overwrite the stored one - join_with_wire_root keys the machine-account wire by id - an UNNAMED room is still a membership (a missing label used to evict a real room) - unsubscribe/parted/set_default operate on ids; set_default names the id it refused Still red and next: the coordinator + account_registry test mods (beacon field rename) and six airc-lib integration tests that call the deleted derive_room_id or pass a ChannelName where a RoomId now goes. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…REGRESSION All in-crate test mods and three of four integration files converted. 343 airc-lib lib tests + every other crate pass. Beacons/registry documents assert on room ids; `daemon_lan_visibility`'s two divergence tests are replaced by one that pins the new law — a sender's channel-NAME header must NEVER redirect a frame (the old `reconverge_by_name` let a peer route into a room it wasn't addressing by sending different text). ⚠ ONE TEST IS RED AND IT IS NOT THE TEST'S FAULT: `airc-cli --test daemon_lifecycle::one_daemon_serves_many_tabs_through_a_full_room_lifecycle` Two tabs on ONE machine both run `airc join general` and no longer land in the same room — each MINTS its own. The second tab's inbox is empty. That is the rendezvous that `derive_room_id(identity, name)` was buying, and I under-called it in the last commit: I described the loss as cross-MACHINE (peers exchange ids), but it is also SAME-machine cross-SCOPE, and that is a live-path break, not a design tradeoff. THE FIX IS NOT TO BRING THE HASH BACK. The account already has a machine-wide home (`machine_account_home` → `wire_root`) shared by every scope; what it lacks is a ROOM DIRECTORY — a persisted `label → RoomId` index that `create` writes and `resolve_or_mint` reads before minting. Then `airc join general` on tab two finds the room tab one minted and JOINS it by id. That is a lookup against shared state, not a derivation: airc still mints, the label is still not the identity, and two rooms may still share a label (the directory holds the first; the rest are id-addressed). Belongs in airc-store as a table, not a JSON sidecar — subscriptions.rs's own module doc says this crate has no sidecars. Next commit. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…e; cross-machine still open
`daemon_lifecycle::one_daemon_serves_many_tabs…` is GREEN again. Two tabs on one
machine that each `airc join general` now land in the same room.
THE MECHANISM: a `room_directory` table (label PK, room_id, claimed_at_ms) in the
account-wide store, plus `EventStore::claim_room_label(label, candidate, now) ->
RoomId` — INSERT-or-ignore then SELECT inside ONE transaction, so two tabs racing
cannot both mint; the loser gets the winner's id back and JOINS it. `resolve_or_mint`
and `ensure_join_context` both go through it.
It is a DISCOVERY index, not an identity. The label is a key HERE and nowhere else;
what it returns is the id, which is still the only address; a room that never passed
through a label (dispatched into, handed over by a peer) is simply absent from the
table and addressed by id like any other; and two rooms may still share a label — the
directory holds the first.
⚠ WHAT THIS DOES NOT FIX, and I am naming it rather than letting the suite imply it:
THREE cross-machine tests in `daemon_lan_visibility` now fail —
inbound_lan_frame_is_visible_in_subscribed_scope_transcript,
unsubscribed_scope_does_not_see_inbound_frame,
delivered_ack_means_visible_to_subscribed_scopes_not_just_durable
— all with `Undeliverable { UnknownChannel }`, all one root cause: the remote machine
has its OWN home and its OWN directory, so `join("store-split-room")` there mints a
different id than the gateway's and the frame addresses a room the gateway never had.
These passed before ONLY because `reconverge_by_name` remapped the frame from the
sender's NAME header — the mechanism deleted in f0e1f65 precisely because it let a
peer redirect a frame by sending different text. So the tests' PREMISE is the deleted
one, but their ASSERTION is a real product requirement and it currently has no
mechanism. Trading one same-machine break for three cross-machine ones is not a win
yet; it is the same gap, relocated and now precisely bounded.
THE REMAINING HALF: the account registry document already crosses machines (gist/LAN)
and already carries a room list — it needs to carry the LABEL alongside each id and
merge into the local directory on refresh, so a peer LEARNS `#general`'s id instead of
minting one. Peers exchange ids; the label rides along as the directory's payload, not
as anyone's identity. That is the next commit and it is not done.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
… discovery, half closed
`AccountRegistryDocument.rooms` is now `Vec<AccountRoom>` — `{ room_id, label:
Option<String> }` — and `import_account_registry_document` feeds every labelled entry
through `claim_room_label`. A peer that syncs the account registry now LEARNS the id
`#general` already has instead of minting a second room nobody is in.
The label is the directory's payload, never anyone's identity: it is claimed
insert-or-ignore (an entry this scope already holds wins, so a peer can never
re-point a label we are using), delivery still routes on the id alone, and an
unlabelled room is a perfectly good entry that simply cannot be reached by name.
The self-beacon publishes its subscriptions' labels; a snapshot-built document has
ids only, because beacons carry addresses.
⚠ THE SAME THREE TESTS ARE STILL RED, and the label plumbing was not enough — I
should say why plainly rather than let the diff imply progress it didn't make.
`daemon_lan_visibility`'s remote never syncs a registry: `dialed_remote` builds a
fresh home, joins by NAME, and dials over LAN. There is no discovery hop on that
path at all, so there is nothing for the labels to arrive through.
AND THE OBVIOUS SHORTCUT IS THE ONE TO REFUSE: the harness holds the gateway handle,
so I could hand the remote the room id directly and the suite would go green. That is
precisely the pattern already on the board as #403 — "every two-node test INJECTS
identity+address; production DERIVES them, and the derivation is what breaks."
Injecting here would make the tests pass while the real flow stayed broken.
WHAT ACTUALLY CLOSES IT: a trusted LAN link needs the same directory exchange the
registry does — on dial, peers share (room_id, label) for rooms they are in, and the
dialer claims them locally. Discovery over the link, not over a gist. That is the next
commit; it is not done, and until it is, two machines meeting for the first time over
bare LAN cannot find each other's `#general` by name.
Everything else green (52 suites).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
The pre-push gate caught it: several of these edits were applied by script and did not respect rustfmt. Formatting only — `cargo check --workspace --tests` is clean before and after, and no logic moved. Fixed rather than skipped. `AIRC_CARGO_GATE_SKIP=1` was right there in the hook's own error text and is exactly the kind of shortcut that turns a shared gate into decoration. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…eparated it from Two gate failures, both fixed rather than skipped — the hook offered AIRC_CARGO_GATE_SKIP=1 and AIRC_CARGO_GATE_CLIPPY=0 in its own error text, and a shared gate you can wave through is decoration. 1. cargo fmt --all. Several of these edits were applied by script and did not respect rustfmt. 2. clippy::empty_line_after_doc_comments in airc.rs. `wall_post_from_event`'s doc block was stranded twelve lines above the function it documents, with WALL_PROJECTION_PAGE_SIZE and its own doc wedged in between — so rustdoc attributed the discriminator's entire "why kind is unreliable on the daemon-attached read path" explanation to a page-size constant. Moved the doc back onto its function. MINE: git log -S dates it to 490dc76 (2026-08-07, the wall_posts_in commit) — I inserted the const between a doc and its function and did not notice. My first draft of this message said "pre-existing on canary", which is true and evasive in the same breath: canary is where my own work lands. Not knowing I wrote something a week ago is amnesia, not exoneration. No logic changed; cargo check --workspace --tests and clippy -D warnings are both clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…e id strings
Three sites where I treated a 128-bit key as text, all added on this branch while
its stated purpose was removing string identity.
1. resolve_room_id PREFIX-MATCHED. It rendered every subscribed room's uuid to a
String and `starts_with`'d an 8..=32 hex token — so 32 hex characters meant 128
bits only when they happened not to collide, and an `Ambiguous` arm existed to
paper over when they did. Now: parse the token to RoomId, one BTreeMap lookup
against the key the map is already keyed by. No scan, no rendered uuid, no
Ambiguous variant. A truncation of an id is NotAnId.
2. JoinIdAmbiguous.candidates was Vec<String> built by
`format!("{} ({})", name, room_id)` — an id packed into display text as the
error's payload, so a caller had to parse text apart to recover the one thing
it needs. Now Vec<RoomId>.
3. InviteBeacon gains `rooms: Vec<RoomId>` — ids only, no labels. A (id, label)
pair on the wire would make the pair the unit of exchange and re-import naming
into the rendezvous this branch exists to remove.
WHY THE BEACON NEEDED ROOMS AT ALL — a real regression this branch introduced,
found by chasing the three red daemon_lan_visibility tests instead of filing them
as a design question. InviteBeacon carried peer_id + peer_spec + endpoints and NO
room. Rooms converged anyway because a room id was v5(account, name): two machines
typing `join general` derived the same id without ever exchanging one. That
derivation WAS the cross-machine room rendezvous — undocumented, load-bearing, and
deleted by this branch. Both sides now mint their own id and address rooms the
other has never heard of. The ids travel explicitly.
Also collapsed Subscription's constructor ladder from four to two: `joining` was a
pure delegation to `with_wire_root` with an identical signature, so choosing
between them carried no information. `minting` (id generated here) and `joining`
(id given) are the only two, and they differ by who owns the id.
Tests: the prefix test pinned the deleted behaviour — rewritten to pin that a
truncated id is not an id. The subscription-order test asserted alphabetical names,
which only held because the map was keyed BY name; keyed by id, iteration order
follows v4 ids and means nothing, so it compares a set.
343 passed / 0 failed; fmt + clippy -D warnings clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…s answer Joel: "don't serialize or even memcpy the struct so to speak, unless absolutely necessary." The other half of pass-the-id — and I broke it on this branch, knowingly, to silence the borrow checker. resolve_or_mint collected `Vec<Subscription>` via `.cloned()` — a String + a PathBuf heap-allocated per label match — and then read exactly one field off each (`s.room_id`) to build the ambiguity error, and took one element on the hit path. The clone existed only to end the immutable borrow on `set` before the function goes on to mutate it. That is cloning to dodge borrowck, which is the tell that the data being carried was the wrong data. `RoomId` is `Copy`. Collecting the IDS ends the borrow just as well, allocates one flat Vec of 16-byte keys, and hands the ambiguity error precisely the candidates it already wanted. N heap clones → 0. One clone remains, on the single-match path, and it is the "unless absolutely necessary" case stated out loud in the comment: the return type is owned and a borrow cannot escape a `&mut set` the caller mutates. Keeping it honest beats pretending it isn't there. Added `SubscriptionSet::get(RoomId) -> Option<&Subscription>` — a map keyed by RoomId had no by-key lookup, so callers reached for `all()` + filter + clone. It returns a BORROW deliberately: a caller that only reads a field should never be handed an owned copy; clone at the boundary that genuinely requires ownership, and only there. 343 passed / 0 failed; fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…ption Same rule, second site, found by scanning for the shape rather than stopping at the one I happened to touch. `Airc::subscriptions()` did `set.all().cloned().collect()` where `set` is loaded fresh on the line above and dropped on the line below — so it copied a `String` + a `PathBuf` per room out of a temporary nobody else could observe, to hand back a Vec it could simply have moved. `into_all()` consumes the set and yields by value. Zero clones. Added `SubscriptionSet::into_all()` as the consuming twin of `all()`: borrow for every caller that reads, move for the one caller that owns the set and is about to drop it. Having both named makes the choice explicit instead of `.cloned()` being the path of least resistance. Also fixed a doc comment that my own re-key made false: `subscriptions()` still claimed "ordering is deterministic by channel name". It has been ordered by ROOM ID since membership moved onto the id — a caller that wants name order sorts by the field it means. Same defect shape as the type-vs-doc disagreement this branch exists to remove, just pointing the other way: the code changed and the prose kept making the old promise. 343 passed / 0 failed; fmt + clippy -D warnings clean. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
…issing
Ten tests across four files were red, all with one cause and it was the
branch's own point turned back on it: two scopes each did `join("name")`
and got two DIFFERENT rooms wearing the same word. A label keys a room
only within ONE account, so across two homes it keys nothing, and every
frame between them came back Undeliverable { UnknownChannel }.
There was no way to say the thing they meant. `join` takes a token a
human typed; nothing took the id a peer had already told you. So the
tests said it in labels and the substrate could not hear them.
Airc::join_room_id(room_id, label) — the id IS the room; the label is
local display, and two scopes may write different words on the same
door.
Both join paths now land in one `commit_join` (save, re-beacon presence,
emit RoomJoined, publish the identity card), so a by-id join is
observable on exactly the terms a by-label one is, with no second
lifecycle to drift.
Tests: one `same_room(label, &[&scopes])` in tests/common — beside
`trust`, its peer-pairing sibling — replaces nine hand-rolled joins.
First scope resolves the label on its account, the rest join that id.
The duplication WAS the bug: each site was individually plausible and
collectively wrong, so it belongs in one place that says why.
Also deleted `SubscriptionSet::join_with_wire_root` — byte-identical to
`join` after the constructor collapse, zero production callers, and its
doc pointed at `Subscription::with_wire_root`, which this branch removed.
Its test was real (it pins the account-wide wire root) and now covers
`join`.
Two stale comments went with it: "same-machine mesh identity resolution
makes the derived RoomId identical" and "both joined to the SAME room
name". Both describe the derivation this branch deleted; leaving them
would have taught the next reader the thing that just broke ten tests.
343 lib + all integration suites green, fmt + clippy clean.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
The PR body named `InviteBeacon.rooms` as this branch's one untested
field. Closing that, and the test turned out to be the branch's
keystone rather than a footnote: it is the only place the whole
cross-account path is asserted end to end.
Two tests, and the second is the one that matters most.
POSITIVE: alice joins a room, publishes `invite_beacon_with_rooms()`,
bob imports it, reads `peer_room_ids()`, and joins BY ID under his own
label. Asserts both scopes hold the same `RoomId` — and that their
LABELS differ, because a matching id with differing labels is the
contract working, not a bug.
NEGATIVE: two accounts each `join("design-review")` and must get
DIFFERENT ids. That is the bug this branch existed to kill, now caught
at build time instead of surfacing as a silent `UnknownChannel` on the
wire. If a label ever keys a room across accounts again, this fails.
Positive-controlled, not just green: swapping `invite_beacon_with_rooms()`
for the room-less `invite_beacon()` fails the first test naming
`advertised: []` — so the assertion demonstrably fires. Restored, green.
Its own file rather than appended to stored_endpoint_dial.rs: that file
is route-discovery/dialing, this is rendezvous, and tests/ is already
one-file-per-concern (channel_purpose, room_roster, default_room_durability).
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
CI caught this, and it is a REAL production bug my branch exposed rather
than a test my branch broke.
`PersonaAgentConfig.room: String` passed a room ACROSS AN ACCOUNT
BOUNDARY by name. A spawned persona opens its own home — its own
account — and a label keys a room only within one account. So parent
and child each resolved "persona-smoke" in their own store, got two
different ids, and every turn the parent requested was delivered to a
room the child was not in. The child then waited out its deadline.
The tempting fix was to make the TEST join by id and leave the config
alone. That leaves production broken and the test green. Fixed the type
instead:
pub room_id: RoomId // the parent already holds it; spawn IS the
// rendezvous, so it passes the id
DELETED `DEFAULT_PERSONA_ROOM`. A default room NAME is this bug in
constant form: it let a child that was told nothing invent a room, land
somewhere its parent isn't, and wait forever. `AIRC_PERSONA_ROOM` is now
REQUIRED and carries the id. A persona that was not told which room to
serve cannot guess, and now says so.
`PERSONA_ROOM_LABEL` replaces it as what it actually is — this scope's
own cosmetic word for the room, explicitly not identity.
--- and the reason every call site could get this wrong ---
`FromStr` on the uuid newtypes (`uuid_newtype!`, so all of them at once).
The macro gave every id `Display` and no inverse. Rendering was free;
parsing cost `Uuid::parse_str(..).map(RoomId::from_uuid)` hand-rolled at
each boundary — so an env var, a CLI arg or a config field that found
that tedious just kept the `String`. That asymmetry is how a display
label becomes an identity: not by a decision, but by the typed path
being fractionally less convenient than the untyped one. Both directions
are free now, and this commit's own parse site is the first caller.
Also: `airc-store::memory` claim/resolve_room_label used `.expect()` on
the directory lock, failing the production no-silent-fallback gate
(`-D clippy::expect_used`) — the second CI red. Now returns
`StoreError::LockPoisoned`, matching the pattern the same file already
uses two functions up. Not an `#[allow]`.
Workspace tests green (20 suites, 0 failures); both clippy gates clean
including the production `unwrap/expect/panic` gate that was red.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo
|
Live reproduction from the M5, 2026-08-14, that I believe lands squarely in this PR's territory (or #401's): Every citizen on this node has been deaf to durable chat since the core reboot ~40 min ago. Ephemeral events ( Ledger state on EVERY scope on this box (home, project, citizen alike):
Also reproduced continuum #348 verbatim: Daemon bounce (stop + join) did NOT heal it. If name-as-identity mis-addressing can strand durable fan-out to same-machine sibling scopes, this PR is the fix; happy to build this branch and trial it live on the M5 — the node is a clean reproduction right now. |
|
Trial result from the M5 (follow-up to my earlier repro comment): this branch does NOT fix the cross-scope durable delivery freeze. Built 5c0b329, installed, restarted the daemon (confirmed running: build 5c0b329, branch feat/room-id-is-the-address), re-sent the in-core durable chat control — after 6 minutes: still in zero citizen scope stores, zero persona turns. Ephemeral fan-out still fine throughout. So the freeze is a separate defect from name-as-identity — durable events reach the daemon (live tails on other scopes see the [Message]) but never replicate into sibling scope stores. The delivery-truth ledger still shows the climbing never-ACKed counter to 4731e245. The M5 remains a clean repro; the room-id work here still stands on its own merits. |
What
Room names are labels. Only the id addresses a room. This branch removes
every place that treated a name — or a rendering of an id — as identity,
and adds the verb that was missing so callers can say what they mean.
The five commits
8ed9f30fmt + reunite a doc comment with its function (self-inflicted in490dc76, attributed in the message)4c5d262kill prefix-matching on rendered uuids, theAmbiguousvariant, andformat!("{name} ({id})")error payloads;InviteBeacon.rooms: Vec<RoomId>; constructors 4→22c91a3fstop cloning N subscriptions to answer what theCopyids already answer; addSubscriptionSet::get9a6b464into_all()— move out of a dying set instead of cloning it46dc5d9Airc::join_room_id(room_id, label)— the rendezvous verb, plus the test cleanup it unblockedWhy the last one was needed
Ten tests across four files went red, and the cause was this branch's own
thesis pointed back at it: two scopes each called
join("name")and got twoDIFFERENT rooms wearing the same word. A label keys a room only within one
account, so across two homes it keys nothing — every frame came back
Undeliverable { UnknownChannel }.There was no API for "join the room whose id I already hold". So the tests
said it in labels, and the substrate could not hear them.
Both join paths now funnel through one
commit_join(save → re-beaconpresence → emit
RoomJoined→ publish identity card), so a by-id join isobservable on exactly the same terms as a by-label one.
Test-side compression
One
same_room(label, &[&scopes])intests/common— besidetrust, itspeer-pairing sibling — replaces nine hand-rolled joins. The duplication WAS
the bug: each site was individually plausible and collectively wrong.
Also deleted
SubscriptionSet::join_with_wire_root: byte-identical tojoinafter the constructor collapse, zero production callers, and its doc pointed
at a method this branch removed. Its test was real and now covers
join.Two stale comments went with it — "same-machine mesh identity resolution makes
the derived RoomId identical" and "both joined to the SAME room name". Both
describe the derivation this branch deletes; leaving them would teach the next
reader the thing that just broke ten tests.
Verification
cargo test -p airc-lib— 343 lib + all 36 integration suites green, 0 failurescargo clippy --workspace --all-targets— cleancargo fmt --all— clean; pre-push gate passed with no skipKnown gap
InviteBeacon.roomshas no direct test yet. The field is carried and typed;the rendezvous path that consumes it is what
join_room_idnow makesexpressible, and wiring a beacon-driven join test is the natural follow-up.
🤖 Generated with Claude Code
https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo