Skip to content

fix(rooms): a room is addressed by its ID — kill name-as-identity end to end - #1361

Open
joelteply wants to merge 15 commits into
canaryfrom
feat/room-id-is-the-address
Open

fix(rooms): a room is addressed by its ID — kill name-as-identity end to end#1361
joelteply wants to merge 15 commits into
canaryfrom
feat/room-id-is-the-address

Conversation

@joelteply

Copy link
Copy Markdown
Contributor

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

  1. 8ed9f30 fmt + reunite a doc comment with its function (self-inflicted in 490dc76, attributed in the message)
  2. 4c5d262 kill prefix-matching on rendered uuids, the Ambiguous variant, and format!("{name} ({id})") error payloads; InviteBeacon.rooms: Vec<RoomId>; constructors 4→2
  3. 2c91a3f stop cloning N subscriptions to answer what the Copy ids already answer; add SubscriptionSet::get
  4. 9a6b464 into_all() — move out of a dying set instead of cloning it
  5. 46dc5d9 Airc::join_room_id(room_id, label) — the rendezvous verb, plus the test cleanup it unblocked

Why 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 two
DIFFERENT 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-beacon
presence → emit RoomJoined → publish identity card), so a by-id join is
observable on exactly the same terms as a by-label one.

Test-side compression

One same_room(label, &[&scopes]) in tests/common — beside trust, its
peer-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 to join
after 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 failures
  • cargo clippy --workspace --all-targets — clean
  • cargo fmt --all — clean; pre-push gate passed with no skip

Known gap

InviteBeacon.rooms has no direct test yet. The field is carried and typed;
the rendezvous path that consumes it is what join_room_id now makes
expressible, and wiring a beacon-driven join test is the natural follow-up.

🤖 Generated with Claude Code

https://claude.ai/code/session_01LoTjvf5j3Ez13g6k8mRkFo

joelteply and others added 15 commits August 14, 2026 07:03
`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
@joelteply

Copy link
Copy Markdown
Contributor Author

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 (ephemeral_coalesced System frames) fan out to citizen scopes continuously — their subscribe streams and WALs stay fresh. Durable Message events never arrive: two operator chat messages (in-core chat/send, continuum envelope) are present in the home-scope store (verified by id) and in ZERO citizen stores after 5+ minutes; persona.inbound.raw_event never fires for them.

Ledger state on EVERY scope on this box (home, project, citizen alike):

  • delivery truth: 4731e245-…: UNPROVEN — 6,717 attempts, never once confirmed
  • route health: 1 route present but NONE MEASURED
  • attempts counter climbing ~25/min (retry loop)

Also reproduced continuum #348 verbatim: airc doctor --fix reports "ok (7 checks clean)" — the three delivery WARNs vanish from the --fix pass instead of being fixed or reported.

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.

@joelteply

Copy link
Copy Markdown
Contributor Author

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.

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.

1 participant