Parent: #45
Blocked by: P2 above (done), ExaDev/wire-mesh#31 (core/room, done), ExaDev/wire-mesh#55/#56/#57/#58 (P3.0 prerequisites)
wire-protocol.ts retires; patch arms become verbs; broadcastPatch becomes directed fan-out; applyPatch becomes the inbound router draining incomingManageRequests. wire-mesh has no dispatch API of its own — agent-comms owns the router.
Design complete: write-the-cddl-spec-calm-pascal-agent-p3-core-room-semantics.md.
Verified against agent-comms v2.0.0 and current wire-mesh: agent id and device-id are now the same value (no translation layer needed, simpler than originally assumed), dmKey's sorted-pair convention is now an exact match for core/room's dm-room-path (only the separator differs), and state-sync-on-join needs no new verb (rides room.members' response via manage-ok's own extension tail).
Two real gaps found: capability-token minting doesn't exist in wire-mesh-core yet (only verification), and delegations-remaining shipped in the spec/schema/vectors but the verifier never reads it. Bigger finding: core/room as merged is materially narrower than as designed (no extension tails on the verb params maps, room-invite carries no token, room-read is single-message). This makes P3.0 a real six-item wire-mesh prerequisite sub-phase, not a one-item token-minting gap:
Four open questions were resolved directly (see the design doc's section 12): correct core/room's content model upstream; converge same-cwd project rooms via deterministic lowest-device-id ownership (reusing the existing coordinator-election race-recovery idiom); accept the owner-must-be-online-to-admit regression as the default, with a per-room delegations-remaining:1 opt-in for backup admitters; restore room.invite's token field.
Phased PR sequence (P3.0 in wire-mesh, P3.1-P3.8 in agent-comms) is in the design doc's section 11.
Progress: P3.1 (room paths), P3.2 (token store + verification seam), P3.3 (verb router) merged. P3.4 (admission) started: createRoom now mints and persists the owner's own self-signed room:member grant (#72) — a real deviation from the design doc found during implementation, since the doc's own delegations-remaining: 0 on that root grant makes it impossible to ever use as a later member grant's parent (no child value is less than 0); a member's own join/invite grant is instead its own independent, parent-less, owner-issued root-level token, which satisfies the same "chain roots at the path's own owner" obligation without the contradiction.
Hit a real blocker starting room.invite's receiving side: the wire-level RoomInvite payload ({verb, invitee, token}) carries only device-ids, no room name/description or inviter name/cwd — but the existing room_invite DeliveryEvent (types.ts) requires roomDescription/fromName/fromCwd as non-optional fields, populated today from the fully-replicated CRDT room/agent tables that P3 is removing. That event type is also shared with the legacy FileStore, so it can't just be narrowed for the new path without a broader, deliberate schema change touching every consumer (bridge.ts, tui.ts, the web frontend, store.ts). This is exactly the dependency the design doc's own phase ordering already implies but doesn't say outright: a good room.invite UX needs P3.6's state-sync-on-join metadata (the room.members response's namespaced room-state extension) to exist first, so the invite handler has something real to populate those fields from, rather than either breaking the shared DeliveryEvent contract or inventing a second, degraded event shape alongside it. Recommend sequencing P3.6 before finishing P3.4's room.invite receiving side, or accepting a deliberately reduced room_invite event (room path + inviter device-id only) as an explicit, tracked interim step with its own schema-migration PR touching every consumer.
Progress: room.join's owner-side admission handler landed (feat/room-join-admission branch, not yet merged) -- registers a room.join room-verb handler that holds the request open until a human calls a new acceptRoomJoin/rejectRoomJoin, then mints and returns an independent, parent-less room:member grant (per the delegations-remaining fix already noted above). WireMeshTransport gained sendRoomRequest(memberId, command, scope, token) for this -- the same method section 8's own fan-out design already names, built now as P3.4's own prerequisite rather than invented twice. MeshStore.joinRoom now sends a real wire-level request when the target room isn't already known locally.
Found while writing the test for this: the legacy full-state-sync mechanism (still active per P3.3's "both paths coexist" transition) means two ordinarily mesh-connected stores are never actually in the "requester has never heard of this room" state the new remote-join path exists for -- handlePeerConnected's own state_sync sends the connecting peer's entire this.rooms map, and createRoom's own broadcastPatch reaches every already-connected peer immediately. So the new wire-level room.join path is real and correct but structurally unreachable in ordinary two-peer testing until P3.8 retires the legacy protocol -- confirmed by writing a two-real-peer integration test first, watching it hang, and finding the requester already had the room via the old gossip path. Tested at the unit level instead (real MeshStore + fake request/handle for the owner side, real MeshStore + fake MeshTransport for the requester side), matching room-router.test.ts's own existing style. Worth keeping in mind for P3.5/P3.6 too: any "does a real two-peer test exercise the new path" question needs this same check first, since the legacy replication will keep winning the race until it's gone.
Still not done in P3.4: room.invite's receiving side (blocked on P3.6 per the earlier finding above), the two-round DM consent flow (section 6), and retargeting mesh_pending/mesh_accept/mesh_reject -- deferred as its own open question rather than guessed at, since it's unclear from the design doc alone whether it means literally repointing those three actions at room admission (retiring connection-level admission as a security boundary) or adding new, additive room-level actions alongside them. Current admission is reachable only by calling MeshStore.acceptRoomJoin/rejectRoomJoin/listPendingRoomJoins directly; no CommsTool action or bridge UI surfaces them yet.
P3.4's named-room admission arc is now complete and merged: #72 (owner root grant), #73 (room.join owner-side handler + requester wire path), #74 (room_accept/room_reject/room_pending operator surface). A named room's owner can now genuinely admit a real, wire-authenticated remote peer end to end, with human-in-the-loop approval, via any bridge whose tool schema exposes the new actions (mcp/codex/claude-code; pi deliberately excluded, matching its own existing choice not to expose mesh_accept/mesh_reject/mesh_pending either).
Remaining for P3.4: the DM two-round consent flow (section 6 -- a genuinely different mechanism from named-room admission, since a DM path's chain roots at the verifier itself, not either participant, so A's own outbound room.join is what authorises B's reply rather than a human approving anything for the DM case) and room.invite's receiving side (still blocked on P3.6 per the earlier note above).
DM two-round consent flow (#75) merged. P3.4 is now complete except room.invite's own receiving side, which stays blocked on P3.6's room-state metadata (per the earlier note above) -- that's the one remaining piece of the phase, everything else (owner grants, named-room join, the operator-facing accept/reject/pending actions, and DM consent) is built, tested, and merged. Moving on to P3.5 (directed fan-out) next, which already has its own prerequisite (sendRoomRequest) built as part of #73.
P3.5 (directed fan-out) started: room.send's own admission side landed (PR #76, not yet merged) -- handleRoomSend verifies a presented room:member token against all six of core/room's obligations, then delivers the message locally once (the manage-response itself is the receipt). sendRoomMessageDirected is the client-side counterpart, sending a real wire-authenticated room.send via the existing sendRoomRequest.
Writing the first real, two-peer integration test that actually sends a cryptographically-signed CapabilityToken over a genuine TCP/TLS connection and verifies it on the receiving end (every prior test either verified tokens purely locally or used fakes that never serialize over real TLS) surfaced a genuine, previously-undiscovered bug in wire-mesh-core itself: any capability token received over a real connection failed signature verification with bad_signature, despite the token's own bytes crossing the wire perfectly intact. Root cause: cbor2 mirrors the class of the buffer it's given for nested byte-string values -- fed a Node Buffer (as both createTcpTransport/createTlsTransport do), it decodes byte strings as Buffer rather than plain Uint8Array, and cbor2's own encode() doesn't recognise a Buffer as a byte string at all, silently mis-encoding it as a generic object instead. COSE signature verification re-encodes exactly those byte strings (Sig_structure), so the corruption only appeared at that point, not in the transmitted bytes themselves. Filed as ExaDev/wire-mesh#89, fixed in ExaDev/wire-mesh#88.
PR #76 is draft and blocked on #89/#88 landing and publishing: its own new test is the thing that exercises the bug, so CI on #76 fails against the currently-published wire-mesh-core until the fix ships.
PR #76 merged: room.send admission and directed delivery landed, verified against the real published ExaDev/wire-mesh#88 fix (wire-mesh-core@1.0.3). P3.5's own core primitive (a token-authorised, wire-authenticated directed send with the manage-response as its receipt) is done; the remaining P3.5 work is the actual multi-member fan-out loop replacing deliverToRoom's legacy iteration, and retiring broadcastPatch/transport.broadcast across every other caller (room create/join/leave/invite/kick/destroy, agent register/update/offline).
PR #77 opened: sendRoomMessage and sendDm now deliver via the real directed room.send fan-out to every member/recipient (deliverRoomSendToMember), replacing broadcastPatch/deliverLocallyAndBroadcast for message delivery specifically, with a retry queue (pendingRoomSends) for members unreachable at send time, flushed on reconnect. handleRoomSend branches on room vs DM path and now supports the reply message-ref and streaming-behavior extension field.
Also fixed a real, previously-latent correctness gap this surfaced: joinRoom treated "room already known locally" (via legacy full-state-sync) as proof of membership, so a self-join for an already-gossiped room never actually went through admission and never got a real token -- a production bridge calling join_room this way could send messages that always failed. The gate is now token presence, not local knowledge of the room.
downtime-replay.test.ts/downtime-replay.integration.test.ts retired per the earlier plan, replaced by room-send-retry-queue.test.ts and room-send-retry.integration.test.ts; the unrelated invite-replay test from the old file survives as invite-replay.test.ts.
Remaining for P3.5: retiring broadcastPatch/transport.broadcast for every OTHER caller (room create/join/leave/invite/kick/destroy, agent register/update/offline) is explicitly out of scope for this PR -- deliberately narrower than the plan's own full P3.5 description, since those calls govern CRDT state-sync of room/agent records, not message delivery, and retiring them is P3.8's own job once P3.6/P3.7 exist. Also still outstanding: replacing the message_read patch with a real room.read verb.
PR #77 merged. sendRoomMessage and sendDm now deliver via the real, wire-authenticated directed fan-out end to end, with a working retry queue for offline members. P3.5's own core scope (directed fan-out, replacing the legacy per-member delivery loop) is done.
Remaining for P3.5, tracked as follow-up rather than closing this phase early: retiring broadcastPatch/transport.broadcast for every OTHER caller (room create/join/leave/invite/kick/destroy, agent register/update/offline) -- deliberately out of scope for #77, since those calls govern CRDT state-sync of room/agent records, not message delivery, and are P3.8's own retirement job once P3.6 (state-sync-on-join) and P3.7 (kick via revocation) exist. Also still outstanding: replacing the message_read patch with a real room.read verb.
PR #78 opened: read receipts now travel via a real, wire-authenticated room.read directed at the message's own author, replacing the legacy message_read patch's mesh-wide broadcast entirely (it's removed from MeshStatePatch). Reuses the room.send fan-out's own per-member send-and-retry machinery (renamed sendRoomRequestToMember/pendingRoomRequests since both verbs need it identically).
With this PR, P3.5 (directed fan-out) is complete: message send, read receipts, and the retry queue for offline members are all real, wire-authenticated, directed requests. The only remaining P3.5-adjacent item is retiring broadcastPatch/transport.broadcast for the non-message callers (room create/join/leave/invite/kick/destroy, agent register/update/offline), which is P3.8's own job once P3.6 (state-sync-on-join) and P3.7 (kick via revocation) exist.
PR #78 merged. P3.5 (directed fan-out) is now complete: room.send, room.read, and the offline-member retry queue all ride real, wire-authenticated directed requests. Remaining work for the phase as a whole is P3.6 (state-sync-on-join, which also unblocks room.invite's receiving side), P3.7 (kick via revocation), and P3.8 (retire broadcastPatch/transport.broadcast for every other caller, and the old protocol entirely).
PR #79 opened: room.join and the new room.members verb now carry the room's real name, description, and type on their manage-ok response (a room-state extension field), so joinRemoteRoom no longer hardcodes placeholders for a room it hasn't seen before. refreshRoomMembers lets a member re-sync its own local room record from the owner on demand.
With this, P3.6 (state-sync-on-join) is complete once #79 merges, which also unblocks room.invite's receiving side (deferred from P3.4, per the earlier note above -- it needed exactly this room-state metadata to populate the room_invite DeliveryEvent's roomDescription/fromName/fromCwd fields). Remaining for the phase as a whole: P3.7 (kick via revocation) and P3.8 (retire broadcastPatch/transport.broadcast for every other caller, and the old protocol entirely).
PR #80 opened: kickFromRoom now revokes the target's own room:member grant for real -- mints a revocation-entry for the token-id admitRoomJoin recorded when it admitted them, records it locally, and announces it to every connected peer via a new transport-level revocation-announce/ingest primitive (onRevocationAnnounce/broadcastRevocation), so a fellow member's own independent token verification rejects the kicked member too, not just the owner's. Confirmed end to end with a real three-peer integration test: a member kicked mid-room stops being able to reach a fellow member with a directed room.send under their now-revoked token.
With this, P3.7 (kick via revocation) is complete once #80 merges. The only remaining piece of P3 as a whole is P3.8: retiring broadcastPatch/transport.broadcast for every other CRDT caller (room create/join/leave/invite/kick/destroy, agent register/update/offline) and the old protocol entirely.
PR #81 opened and merged: room.invite now travels over a real, wire-authenticated request (the first slice of P3.8) -- inviteToRoom mints and pushes a room:member grant directly to the target, replacing the broadcastPatch/deliverLocallyAndBroadcast pair that used to notify every mesh-connected peer just to reach one target. handleRoomInvite (the new receiving side) verifies the embedded token names the recipient as bearer, persists it, and fires a local room_invite event carrying the room's real name/description and the inviter's real name/cwd via open extension fields, the same convention P3.6's room-state already established. The invite's own token-id is now recorded via saveIssuedRoomGrant, so an invited member is kickable exactly like a joined one.
A real, previously-hidden blocker surfaced and got resolved in the process: declineInvite has no wire mechanism at all today for a target to actually tell the owner it declined -- it's written as if it always runs on a store holding the owner's own authoritative Room object, which was true under full CRDT replication but isn't anymore now that room state is owner-local. Declining, leaving, and destroying a room all still ride the legacy broadcastPatch and need their own real wire-level design (possibly a new wire-mesh spec verb for decline specifically) before their own broadcasts can retire.
A second real blocker surfaced: createRoom's own broadcastPatch can't simply be removed either, even though nothing strictly needs it anymore for join/invite to work -- public and private rooms are documented as discoverable via list_rooms before anyone has joined or been invited to them (only secret rooms are meant to stay hidden), and that discoverability currently depends entirely on the room object being gossiped to every peer at creation time. Retiring it needs a real, lightweight room-directory mechanism as a replacement, not a quick deletion.
Remaining for P3.8: declineInvite/leaveRoom/destroyRoom's own broadcastPatch retirement (each likely needing new wire-level verbs, at least for decline), a room-discovery replacement for createRoom's broadcast, and agent register/update/offline (blocked on P4's gossip-based presence work landing first, since that's the mechanism meant to replace it). None of P3.8's remaining scope is straightforward removal -- each piece needs its own design pass, matching how P3.4-P3.7 each needed one.
PR #82 opened: leaveRoom and declineInvite both now go over a real, wire-authenticated room.leave when the caller is leaving/declining for itself (not the room's own owner) -- the owner's own receiving side (handleRoomLeave) revokes the sender's grant for real via a shared revokeMemberGrant helper (factored out of kickFromRoom, which now shares it too) and fires the matching member_left or invite_declined notification, telling the two cases apart from its own membership/invited lists rather than a separate verb. This also fixed a real, previously-unnoticed bug: declineInvite read a locally cached Room object handleRoomInvite never actually constructs, so a real decline under the current invite flow would have thrown ROOM_NOT_FOUND.
With this, the two-way membership operations (join, invite, leave, decline, kick) all revoke for real and are gated the same way. Remaining for P3.8: destroyRoom's own broadcastPatch retirement, a real replacement for createRoom's own room-existence broadcast (public/private rooms need to stay discoverable via list_rooms before anyone's joined -- see the earlier note above), the broader deliverToRoom/deliverLocallyAndBroadcast retirement for informational events (member_joined/member_left/member_status currently still ride the legacy mesh-wide "delivery" patch even where a directed alternative now exists), and agent register/update/offline (blocked on P4's presence work landing first).
PR #83 opened: destroyRoom now revokes every member's own grant for real via the same revokeMemberGrant helper, before its existing cleanup and room_delete broadcast.
With this, every membership-lifecycle operation that removes someone from a room (kick, leave, decline, destroy) revokes their real token, not just the CRDT record. Remaining for P3.8: a real replacement for createRoom's own room-existence broadcast (needs a lightweight discovery mechanism, not a quick removal -- public/private rooms must stay discoverable via list_rooms before anyone's joined), the broader deliverToRoom/deliverLocallyAndBroadcast retirement for informational events (member_joined/member_left/member_status/room_delete/invite_declined all still ride the legacy mesh-wide "delivery" patch even where each event's own audience is already directed), and agent register/update/offline (blocked on P4's presence work landing first). None of the three remaining pieces is a quick removal -- each needs its own design pass, matching every other piece of this phase so far.
Room-discovery replacement, initial design direction (2026-09-14) — grounded against the real Room schema and the existing presence-gossip precedent, not fully resolved. Room (src/core/types.ts's RoomSchema) carries id (the room path), name, type, owner, createdAt, description plus CRDT membership bookkeeping irrelevant to discovery -- the minimal tuple worth gossiping is {path, name, type, description}, type filtered to public/private only (never secret, matching the existing rule that only those two stay pre-join discoverable).
The mechanism to reuse: WireMeshTransport already has exactly this shape once, for presence (reportPresenceAdvert, wire-mesh-transport.ts:383-395) -- a domain-qualified gossip extension key (presence/status) re-advertised on a timer via sendGossipUpdate, read back off a session's own directory: readonly DirectoryEntry[]. #87 (wire-mesh, already closed) established the identical pattern for delegable-grant candidates (capability-request/candidates). A room/hosted key carrying each owner's own currently-owned public/private rooms is a third instance of the same convention, not a new mechanism: createRoom/destroyRoom update local "rooms I own" state, the next gossip re-advert cycle carries it, and listRooms merges in any peer's advertised entries not already locally known (deduplicated by path) as lightweight, not-yet-joined discoverable rows.
Not yet resolved, the concrete next step for whoever implements this: reportPresenceAdvert's own scoping is deliberately narrow -- it reads only the directory entry for the one session's own authenticated peer, never any other device a multi-hop directory might mention. Room discovery needs the opposite: every currently-known device's advertised hosted-rooms list, mesh-wide, not just directly-connected peers. Whether WireMeshTransport/MeshTransport (the legacy coordinator-oriented port WireMeshTransport itself adapts underneath, transport.ts:142) already exposes an all-peers directory aggregation anywhere, or whether one needs to be added, wasn't checked before this session's context ran out on this investigation -- check that first, since it determines whether this is a small addition (aggregation already exists) or needs its own transport-layer method first.
Checked (2026-09-14): no all-peers directory aggregation exists anywhere in agent-comms today -- confirmed, not just unconfirmed. listAgents (agent-registry.ts:140), the closest existing precedent for "discover everything currently known mesh-wide," still reads entirely from this.deps.agents, a plain local Map kept in sync only by the legacy broadcastPatch/CRDT-replication mechanism (agent register/update/offline) -- the exact mechanism P3.8's own remaining scope already says is "blocked on P4's gossip-based presence work landing first." So room-discovery isn't a standalone gap: it needs the identical underlying prerequisite agent-discovery-via-gossip already needs and doesn't have -- a real mesh-wide gossip directory aggregation in WireMeshTransport, not per-session reportPresenceAdvert-style single-peer scoping. Practical conclusion: sequence room-discovery's own implementation after P4's gossip-based presence work lands, since building it first would mean either duplicating whatever aggregation mechanism P4 ends up building, or building a second one only room-discovery uses. Both room/hosted (this item) and agent register/update/offline's own retirement should reuse the same aggregation primitive once P4 builds it, not invent two.
Room-discovery's prerequisite aggregation built and merged (2026-09-16, PR #130): WireMeshTransport.listKnownDevices(). Merges every live session's own peer-advert directory into one device-id-keyed map, mesh-wide, not just directly-connected peers -- the exact gap the last update above identified as missing before room-discovery (or agent register/update/offline's own retirement) could proceed.
Write half of room-discovery landed too (2026-09-16, PR #131): room/hosted gossip. WireMeshTransport's presence-readvertise mechanism generalised into readvertiseGossip, which now also carries this side's own currently-hosted public/private rooms via an optional getHostedRooms callback, the same pull-not-push shape getCurrentPresence already established. A peer's listKnownDevices() can now read another device's hosted-rooms advert directly under room/hosted.
Still remaining for P3.8's room-discovery piece: wiring getHostedRooms at the bridge-mesh.ts/mesh-store.ts construction site (reading RoomLifecycle's own locally-owned public/private rooms), and merging a gossip-discovered room into listRooms's own return value -- deliberately deferred, since a gossip-discovered, not-yet-joined room needs its own synthesized shape decided (a full Room object carries membership/grant fields a lightweight advert doesn't have), not just a mechanical splice into the existing rooms Map. createRoom's own broadcastPatch stays in place until the gossip-based replacement is wired and verified equivalent -- not removed in the same change that adds the new mechanism, matching how every other P3 piece added its real mechanism before retiring the legacy one.
Also still remaining, noted but not picked up here: agent register/update/offline's own retirement, which the last update already flagged as needing the identical listKnownDevices() prerequisite -- now unblocked, but needs its own design pass for what AgentIdentity fields (name, tags, visibility, pid) get gossiped versus what stays CRDT-only, since only presence/status is gossiped today.
PR #132 merged (2026-09-16): the gossip mechanism now carries real room data end to end. MeshStore.hostedRooms reads this store's own currently-owned public/private rooms out of the existing rooms Map, and bridge-mesh.ts wires it into WireMeshTransport's getHostedRooms callback -- the same construction site selfStatus already feeds getCurrentPresence through. createRoom's own room_upsert broadcast stays untouched.
What's left for room-discovery, concretely: merging a gossip-discovered room (read via listKnownDevices()'s room/hosted advert) into listRooms's own return value, for a peer that hasn't joined yet. This is the one piece still deliberately deferred -- it needs a decision on a not-yet-joined room's synthesized shape (a full Room carries membership/grant fields a lightweight advert doesn't have: members, invited, memberJoins, etc.), not a mechanical splice. Once that lands and is verified equivalent to what room_upsert broadcast already provides, createRoom's own broadcast can retire.
PR #138 merged (2026-09-16): room-discovery's read side is real. listRooms now merges gossip-discovered rooms. A public/private room heard via room/hosted gossip (#131/#132) but never locally known is surfaced as a display-only placeholder (real id/name/type/owner/description, empty membership fields) mixed into listRooms' existing Room[] result; a room already known locally always wins over its own gossip advert. MeshTransport gained an optional listKnownDevices method so RoomLifecycle reads through the existing abstract requireTransport() port rather than depending on the concrete WireMeshTransport type. A real bug (extracting the method before calling it, dropping its this binding) was caught by running the full test suite before this ever reached CI, not just the new test file.
Room-discovery is now complete end to end: write side (#131/#132) and read side (#138) both merged and tested. createRoom's own room_upsart broadcast retirement is the one piece still deliberately left for later -- it needs production verification that the gossip-based picture is equivalent before removing the legacy mechanism, matching how every other P3 piece added its real mechanism before retiring the old one, not in the same change.
What's left in P3.8 overall, unchanged from earlier updates: the broader deliverToRoom/deliverLocallyAndBroadcast retirement for informational events (member_joined/member_left/member_status/room_delete/invite_declined still ride the legacy mesh-wide "delivery" patch even where each event's own audience is already directed), and agent register/update/offline's own retirement (needs a design pass on which AgentIdentity fields get gossiped versus stay CRDT-only).
PR #139 merged (2026-09-16): agent-discovery's write and read sides are both real now, mirroring room-discovery (#131/#132/#138) closely. WireMeshTransport's gossip tick gained a third optional source (getSelfAgentAdvert), gossiping name/harness/cwd/pid/startedAt/tags/subscribedRooms under agent/self (status reused from the existing presence/status key rather than duplicated). MeshStore.selfAgentAdvert withholds the advert entirely for anything other than "visible" -- gossip reaches every connected peer regardless of mesh-approval status, so a hidden or ghost agent never advertises this way. AgentRegistry.listAgents merges in a gossip-discovered agent not otherwise locally known, as a display-only placeholder, never merged into the local agents map, always shadowed by a real local record.
Both halves of P3.8's own room-discovery and agent-discovery gossip mechanisms are now fully built. What remains is the actual retirement of each legacy CRDT broadcast (createRoom's room_upsert, agent register/update/offline) -- deliberately not done here, since each needs production verification that the gossip-based picture is equivalent before removing the mechanism it replaces, matching how every other piece of this phase added its real mechanism first and retired the old one as a separate, later step. The broader deliverToRoom/deliverLocallyAndBroadcast retirement for informational events is the one piece of P3.8 with no gossip-based replacement designed yet.
PR #140 merged (2026-09-16): the informational-event broadcast retirement now has a real mechanism, at least for deliverToRoom's own fan-out. deliverToRoom no longer rides deliverLocallyAndBroadcast's mesh-wide broadcastPatch per member -- it fires local delivery directly for this store's own agent and sends a real, wire-authenticated room.notify (a new agent-comms-only verb, no wire-mesh spec change needed) to every other member, matching room.send/room.read's own established directed-fan-out shape. member_status, member_joined, name_changed, and every other event deliverToRoom carries now goes out this way.
What's genuinely left in #48: deliverLocallyAndBroadcast itself is untouched and still backs every OTHER caller -- federation-bridge.ts's own federated-room notifications, room-lifecycle.ts's own invite/join-related deliveries, and emitDeliveryStatus. Converting those is its own separate, still-open slice of the same retirement (each has its own audience-shape question worth its own look, not a mechanical find-and-replace). Beyond that, the two items already tracked in earlier updates remain: retiring createRoom's own room_upsert broadcast now that room-discovery's gossip mechanism exists (needs production verification first), and agent register/update/offline's own retirement (needs a design pass on which AgentIdentity fields get gossiped).
PR #141 merged (2026-09-16): two more deliverLocallyAndBroadcast call sites in delivery-engine.ts converted to directed room.notify. notifyRoomsOfNameChange's own trailing self-confirmation delivery (traced every updateAgent caller and confirmed the renamed agent can genuinely be remote -- the web console's rename_agent action takes an arbitrary agent id from the directory, not just this store's own peer) now addresses the renamed agent via the implicit DM path when remote, falling back to plain local queueing for a real self-rename. emitDeliveryStatus (the delivery-receipt notification back to a message's sender, who can obviously be remote) now resolves the message's own room-path or DM-path via the already-existing findMessageLocation (previously only used by markRead) and sends the receipt directly. Both share a new private deliverToMember helper, which deliverToRoom was also refactored to use instead of duplicating its per-member body. findMessageSender was removed as dead code once findMessageLocation covered its one remaining caller.
What's genuinely left in #48, narrowed further: federation-bridge.ts's own federated-room-message fan-out (onRoomMessage, loops over federated room members) and room-lifecycle.ts's post-join member-list delivery (joinRoom's own room_members event to the joining agent, who -- per joinRoom's own doc comment about convergence/admin bookkeeping joining on behalf of a different agentId -- can also be remote) are the only two deliverLocallyAndBroadcast call sites left uncontverted, each still worth its own focused look at its audience shape before converting. Beyond that, the two items already tracked in earlier updates remain unchanged: retiring createRoom's own room_upsert broadcast now that room-discovery's gossip mechanism exists (needs production verification first), and agent register/update/offline's own retirement (needs a design pass on which AgentIdentity fields get gossiped).
PR #142 merged (2026-09-16): the last two deliverLocallyAndBroadcast call sites are converted. Every mesh-wide broadcast this issue set out to retire for informational/message delivery is now gone. federation-bridge.ts's own onRoomMessage local fan-out now calls a new DeliveryEngine.deliverRoomMessageToMember per real local member (skipping fed:-prefixed shadow members entirely, per the same filtering convention getFederatedRoomMemberships already used -- they have no addressable mesh device of their own, federation.ts's own link forwarding is what reaches the real remote participant). room-lifecycle.ts's own post-join room_members delivery now calls deliverToMember directly (made public on DeliveryEngine for this second external caller). A real regression was found and fixed along the way: room-send-retry-queue.test.ts's exact-total-count assertion broke once joinRoom's own new directed room.notify became a second, legitimately-queued retry for the same member -- fixed by asserting on the specific message's own retry count rather than a brittle total.
deliverLocallyAndBroadcast itself is now unused in production code (only the generic public deliver() CommsStore method still calls it, which remains the documented entry point for a caller that wants the legacy broadcast semantics). The directed-delivery retirement this issue tracked (member_joined/member_left/member_status/room_delete/invite_declined/room_message/name_changed/delivery_status/room_members, across deliverToRoom, notifyRoomsOfNameChange, emitDeliveryStatus, onRoomMessage, and joinRoom) is complete.
What's left in #48, unchanged from earlier updates: retiring createRoom's own room_upsert broadcast now that room-discovery's gossip mechanism exists (needs production verification first), and agent register/update/offline's own retirement (needs a design pass on which AgentIdentity fields get gossiped versus stay CRDT-only).
Correction (2026-09-16): both remaining items above are resolved by correcting the framing, not by further implementation. This closes #48.
The directed-delivery retirement completed in PRs #140-#142 was correct specifically because a deliverLocallyAndBroadcast event's true audience (a specific room's members) is bounded and narrower than "every mesh peer" -- broadcasting it mesh-wide was a genuine mistargeting bug. agent_upsert/agent_offline and room_upsert are structurally different: their true audience genuinely is every mesh peer (any peer's list_agents/list_rooms may legitimately show any agent or any public/private room), so mesh-wide propagation is the correct design for them, not a retirement candidate.
Checked directly against delivery-engine.ts's applyPatch: agent_upsert carries real CRDT-convergence semantics gossip has no equivalent for today -- a version field gating stale-copy rejection, plus a concurrent-mutation tie-break that unions subscribedRooms when two peers bump the same base version independently. listAgents/listRooms' own gossip-merge (#131/#132/#138/#139) deliberately only synthesizes a placeholder for an entity not already locally known, and never touches the real agents/rooms Map for one that already is -- that's by design in every one of those PRs' own doc comments, not an oversight to fix. Rebuilding gossip to carry real versioned merge semantics just to replace an already-correct broadcast would be new machinery solving a problem that doesn't exist, and would trade push/low-latency/correctly-converging updates for staleness up to one gossip interval -- a real regression, not an improvement.
agent_offline's coordinator-driven stale-peer-probe path (stale-agent-checker.ts) makes the same point from a different angle: it's a third-party declaration ("the coordinator's own PID probe says peer X is dead"), which has no gossip equivalent at all -- gossip is inherently self-asserted, so nothing but a broadcast can carry a third party's claim about someone else's liveness.
Gossip's role stays exactly what it was already built to be: fast, cheap discovery of an entity this store hasn't yet been told about via the mesh's own real-time replication, complementing agent_upsert/room_upsert's broadcast, not replacing it.
Every other piece of #48's own scope (two-way membership-lifecycle revocation across join/invite/leave/decline/kick/destroy, and the full directed-delivery retirement for informational events) is done, merged, and tested. Closing.
Parent: #45
Blocked by: P2 above (done), ExaDev/wire-mesh#31 (core/room, done), ExaDev/wire-mesh#55/#56/#57/#58 (P3.0 prerequisites)
wire-protocol.ts retires; patch arms become verbs; broadcastPatch becomes directed fan-out; applyPatch becomes the inbound router draining incomingManageRequests. wire-mesh has no dispatch API of its own — agent-comms owns the router.
Design complete:
write-the-cddl-spec-calm-pascal-agent-p3-core-room-semantics.md.Verified against agent-comms v2.0.0 and current wire-mesh: agent id and device-id are now the same value (no translation layer needed, simpler than originally assumed), dmKey's sorted-pair convention is now an exact match for core/room's dm-room-path (only the separator differs), and state-sync-on-join needs no new verb (rides room.members' response via manage-ok's own extension tail).
Two real gaps found: capability-token minting doesn't exist in wire-mesh-core yet (only verification), and delegations-remaining shipped in the spec/schema/vectors but the verifier never reads it. Bigger finding: core/room as merged is materially narrower than as designed (no extension tails on the verb params maps, room-invite carries no token, room-read is single-message). This makes P3.0 a real six-item wire-mesh prerequisite sub-phase, not a one-item token-minting gap:
Four open questions were resolved directly (see the design doc's section 12): correct core/room's content model upstream; converge same-cwd project rooms via deterministic lowest-device-id ownership (reusing the existing coordinator-election race-recovery idiom); accept the owner-must-be-online-to-admit regression as the default, with a per-room delegations-remaining:1 opt-in for backup admitters; restore room.invite's token field.
Phased PR sequence (P3.0 in wire-mesh, P3.1-P3.8 in agent-comms) is in the design doc's section 11.
Progress: P3.1 (room paths), P3.2 (token store + verification seam), P3.3 (verb router) merged. P3.4 (admission) started:
createRoomnow mints and persists the owner's own self-signedroom:membergrant (#72) — a real deviation from the design doc found during implementation, since the doc's owndelegations-remaining: 0on that root grant makes it impossible to ever use as a later member grant'sparent(no child value is less than 0); a member's own join/invite grant is instead its own independent, parent-less, owner-issued root-level token, which satisfies the same "chain roots at the path's own owner" obligation without the contradiction.Hit a real blocker starting
room.invite's receiving side: the wire-levelRoomInvitepayload ({verb, invitee, token}) carries only device-ids, no room name/description or inviter name/cwd — but the existingroom_inviteDeliveryEvent(types.ts) requiresroomDescription/fromName/fromCwdas non-optional fields, populated today from the fully-replicated CRDT room/agent tables that P3 is removing. That event type is also shared with the legacyFileStore, so it can't just be narrowed for the new path without a broader, deliberate schema change touching every consumer (bridge.ts,tui.ts, the web frontend,store.ts). This is exactly the dependency the design doc's own phase ordering already implies but doesn't say outright: a goodroom.inviteUX needs P3.6's state-sync-on-join metadata (theroom.membersresponse's namespacedroom-stateextension) to exist first, so the invite handler has something real to populate those fields from, rather than either breaking the shared DeliveryEvent contract or inventing a second, degraded event shape alongside it. Recommend sequencing P3.6 before finishing P3.4'sroom.invitereceiving side, or accepting a deliberately reducedroom_inviteevent (room path + inviter device-id only) as an explicit, tracked interim step with its own schema-migration PR touching every consumer.Progress: room.join's owner-side admission handler landed (feat/room-join-admission branch, not yet merged) -- registers a
room.joinroom-verb handler that holds the request open until a human calls a newacceptRoomJoin/rejectRoomJoin, then mints and returns an independent, parent-less room:member grant (per the delegations-remaining fix already noted above).WireMeshTransportgainedsendRoomRequest(memberId, command, scope, token)for this -- the same method section 8's own fan-out design already names, built now as P3.4's own prerequisite rather than invented twice.MeshStore.joinRoomnow sends a real wire-level request when the target room isn't already known locally.Found while writing the test for this: the legacy full-state-sync mechanism (still active per P3.3's "both paths coexist" transition) means two ordinarily mesh-connected stores are never actually in the "requester has never heard of this room" state the new remote-join path exists for --
handlePeerConnected's own state_sync sends the connecting peer's entirethis.roomsmap, andcreateRoom's ownbroadcastPatchreaches every already-connected peer immediately. So the new wire-level room.join path is real and correct but structurally unreachable in ordinary two-peer testing until P3.8 retires the legacy protocol -- confirmed by writing a two-real-peer integration test first, watching it hang, and finding the requester already had the room via the old gossip path. Tested at the unit level instead (real MeshStore + fake request/handle for the owner side, real MeshStore + fake MeshTransport for the requester side), matching room-router.test.ts's own existing style. Worth keeping in mind for P3.5/P3.6 too: any "does a real two-peer test exercise the new path" question needs this same check first, since the legacy replication will keep winning the race until it's gone.Still not done in P3.4:
room.invite's receiving side (blocked on P3.6 per the earlier finding above), the two-round DM consent flow (section 6), and retargetingmesh_pending/mesh_accept/mesh_reject-- deferred as its own open question rather than guessed at, since it's unclear from the design doc alone whether it means literally repointing those three actions at room admission (retiring connection-level admission as a security boundary) or adding new, additive room-level actions alongside them. Current admission is reachable only by callingMeshStore.acceptRoomJoin/rejectRoomJoin/listPendingRoomJoinsdirectly; no CommsTool action or bridge UI surfaces them yet.P3.4's named-room admission arc is now complete and merged: #72 (owner root grant), #73 (room.join owner-side handler + requester wire path), #74 (room_accept/room_reject/room_pending operator surface). A named room's owner can now genuinely admit a real, wire-authenticated remote peer end to end, with human-in-the-loop approval, via any bridge whose tool schema exposes the new actions (mcp/codex/claude-code; pi deliberately excluded, matching its own existing choice not to expose mesh_accept/mesh_reject/mesh_pending either).
Remaining for P3.4: the DM two-round consent flow (section 6 -- a genuinely different mechanism from named-room admission, since a DM path's chain roots at the verifier itself, not either participant, so A's own outbound room.join is what authorises B's reply rather than a human approving anything for the DM case) and room.invite's receiving side (still blocked on P3.6 per the earlier note above).
DM two-round consent flow (#75) merged. P3.4 is now complete except
room.invite's own receiving side, which stays blocked on P3.6's room-state metadata (per the earlier note above) -- that's the one remaining piece of the phase, everything else (owner grants, named-room join, the operator-facing accept/reject/pending actions, and DM consent) is built, tested, and merged. Moving on to P3.5 (directed fan-out) next, which already has its own prerequisite (sendRoomRequest) built as part of #73.P3.5 (directed fan-out) started: room.send's own admission side landed (PR #76, not yet merged) --
handleRoomSendverifies a presentedroom:membertoken against all six of core/room's obligations, then delivers the message locally once (the manage-response itself is the receipt).sendRoomMessageDirectedis the client-side counterpart, sending a real wire-authenticatedroom.sendvia the existingsendRoomRequest.Writing the first real, two-peer integration test that actually sends a cryptographically-signed
CapabilityTokenover a genuine TCP/TLS connection and verifies it on the receiving end (every prior test either verified tokens purely locally or used fakes that never serialize over real TLS) surfaced a genuine, previously-undiscovered bug in wire-mesh-core itself: any capability token received over a real connection failed signature verification withbad_signature, despite the token's own bytes crossing the wire perfectly intact. Root cause: cbor2 mirrors the class of the buffer it's given for nested byte-string values -- fed a NodeBuffer(as bothcreateTcpTransport/createTlsTransportdo), it decodes byte strings asBufferrather than plainUint8Array, and cbor2's ownencode()doesn't recognise aBufferas a byte string at all, silently mis-encoding it as a generic object instead. COSE signature verification re-encodes exactly those byte strings (Sig_structure), so the corruption only appeared at that point, not in the transmitted bytes themselves. Filed as ExaDev/wire-mesh#89, fixed in ExaDev/wire-mesh#88.PR #76 is draft and blocked on #89/#88 landing and publishing: its own new test is the thing that exercises the bug, so CI on #76 fails against the currently-published wire-mesh-core until the fix ships.
PR #76 merged: room.send admission and directed delivery landed, verified against the real published ExaDev/wire-mesh#88 fix (wire-mesh-core@1.0.3). P3.5's own core primitive (a token-authorised, wire-authenticated directed send with the manage-response as its receipt) is done; the remaining P3.5 work is the actual multi-member fan-out loop replacing deliverToRoom's legacy iteration, and retiring broadcastPatch/transport.broadcast across every other caller (room create/join/leave/invite/kick/destroy, agent register/update/offline).
PR #77 opened: sendRoomMessage and sendDm now deliver via the real directed room.send fan-out to every member/recipient (deliverRoomSendToMember), replacing broadcastPatch/deliverLocallyAndBroadcast for message delivery specifically, with a retry queue (pendingRoomSends) for members unreachable at send time, flushed on reconnect. handleRoomSend branches on room vs DM path and now supports the reply message-ref and streaming-behavior extension field.
Also fixed a real, previously-latent correctness gap this surfaced: joinRoom treated "room already known locally" (via legacy full-state-sync) as proof of membership, so a self-join for an already-gossiped room never actually went through admission and never got a real token -- a production bridge calling join_room this way could send messages that always failed. The gate is now token presence, not local knowledge of the room.
downtime-replay.test.ts/downtime-replay.integration.test.ts retired per the earlier plan, replaced by room-send-retry-queue.test.ts and room-send-retry.integration.test.ts; the unrelated invite-replay test from the old file survives as invite-replay.test.ts.
Remaining for P3.5: retiring broadcastPatch/transport.broadcast for every OTHER caller (room create/join/leave/invite/kick/destroy, agent register/update/offline) is explicitly out of scope for this PR -- deliberately narrower than the plan's own full P3.5 description, since those calls govern CRDT state-sync of room/agent records, not message delivery, and retiring them is P3.8's own job once P3.6/P3.7 exist. Also still outstanding: replacing the message_read patch with a real room.read verb.
PR #77 merged. sendRoomMessage and sendDm now deliver via the real, wire-authenticated directed fan-out end to end, with a working retry queue for offline members. P3.5's own core scope (directed fan-out, replacing the legacy per-member delivery loop) is done.
Remaining for P3.5, tracked as follow-up rather than closing this phase early: retiring broadcastPatch/transport.broadcast for every OTHER caller (room create/join/leave/invite/kick/destroy, agent register/update/offline) -- deliberately out of scope for #77, since those calls govern CRDT state-sync of room/agent records, not message delivery, and are P3.8's own retirement job once P3.6 (state-sync-on-join) and P3.7 (kick via revocation) exist. Also still outstanding: replacing the message_read patch with a real room.read verb.
PR #78 opened: read receipts now travel via a real, wire-authenticated room.read directed at the message's own author, replacing the legacy message_read patch's mesh-wide broadcast entirely (it's removed from MeshStatePatch). Reuses the room.send fan-out's own per-member send-and-retry machinery (renamed sendRoomRequestToMember/pendingRoomRequests since both verbs need it identically).
With this PR, P3.5 (directed fan-out) is complete: message send, read receipts, and the retry queue for offline members are all real, wire-authenticated, directed requests. The only remaining P3.5-adjacent item is retiring broadcastPatch/transport.broadcast for the non-message callers (room create/join/leave/invite/kick/destroy, agent register/update/offline), which is P3.8's own job once P3.6 (state-sync-on-join) and P3.7 (kick via revocation) exist.
PR #78 merged. P3.5 (directed fan-out) is now complete: room.send, room.read, and the offline-member retry queue all ride real, wire-authenticated directed requests. Remaining work for the phase as a whole is P3.6 (state-sync-on-join, which also unblocks room.invite's receiving side), P3.7 (kick via revocation), and P3.8 (retire broadcastPatch/transport.broadcast for every other caller, and the old protocol entirely).
PR #79 opened: room.join and the new room.members verb now carry the room's real name, description, and type on their manage-ok response (a room-state extension field), so joinRemoteRoom no longer hardcodes placeholders for a room it hasn't seen before. refreshRoomMembers lets a member re-sync its own local room record from the owner on demand.
With this, P3.6 (state-sync-on-join) is complete once #79 merges, which also unblocks room.invite's receiving side (deferred from P3.4, per the earlier note above -- it needed exactly this room-state metadata to populate the room_invite DeliveryEvent's roomDescription/fromName/fromCwd fields). Remaining for the phase as a whole: P3.7 (kick via revocation) and P3.8 (retire broadcastPatch/transport.broadcast for every other caller, and the old protocol entirely).
PR #80 opened: kickFromRoom now revokes the target's own room:member grant for real -- mints a revocation-entry for the token-id admitRoomJoin recorded when it admitted them, records it locally, and announces it to every connected peer via a new transport-level revocation-announce/ingest primitive (onRevocationAnnounce/broadcastRevocation), so a fellow member's own independent token verification rejects the kicked member too, not just the owner's. Confirmed end to end with a real three-peer integration test: a member kicked mid-room stops being able to reach a fellow member with a directed room.send under their now-revoked token.
With this, P3.7 (kick via revocation) is complete once #80 merges. The only remaining piece of P3 as a whole is P3.8: retiring broadcastPatch/transport.broadcast for every other CRDT caller (room create/join/leave/invite/kick/destroy, agent register/update/offline) and the old protocol entirely.
PR #81 opened and merged: room.invite now travels over a real, wire-authenticated request (the first slice of P3.8) -- inviteToRoom mints and pushes a room:member grant directly to the target, replacing the broadcastPatch/deliverLocallyAndBroadcast pair that used to notify every mesh-connected peer just to reach one target. handleRoomInvite (the new receiving side) verifies the embedded token names the recipient as bearer, persists it, and fires a local room_invite event carrying the room's real name/description and the inviter's real name/cwd via open extension fields, the same convention P3.6's room-state already established. The invite's own token-id is now recorded via saveIssuedRoomGrant, so an invited member is kickable exactly like a joined one.
A real, previously-hidden blocker surfaced and got resolved in the process: declineInvite has no wire mechanism at all today for a target to actually tell the owner it declined -- it's written as if it always runs on a store holding the owner's own authoritative Room object, which was true under full CRDT replication but isn't anymore now that room state is owner-local. Declining, leaving, and destroying a room all still ride the legacy broadcastPatch and need their own real wire-level design (possibly a new wire-mesh spec verb for decline specifically) before their own broadcasts can retire.
A second real blocker surfaced: createRoom's own broadcastPatch can't simply be removed either, even though nothing strictly needs it anymore for join/invite to work -- public and private rooms are documented as discoverable via list_rooms before anyone has joined or been invited to them (only secret rooms are meant to stay hidden), and that discoverability currently depends entirely on the room object being gossiped to every peer at creation time. Retiring it needs a real, lightweight room-directory mechanism as a replacement, not a quick deletion.
Remaining for P3.8: declineInvite/leaveRoom/destroyRoom's own broadcastPatch retirement (each likely needing new wire-level verbs, at least for decline), a room-discovery replacement for createRoom's broadcast, and agent register/update/offline (blocked on P4's gossip-based presence work landing first, since that's the mechanism meant to replace it). None of P3.8's remaining scope is straightforward removal -- each piece needs its own design pass, matching how P3.4-P3.7 each needed one.
PR #82 opened: leaveRoom and declineInvite both now go over a real, wire-authenticated room.leave when the caller is leaving/declining for itself (not the room's own owner) -- the owner's own receiving side (handleRoomLeave) revokes the sender's grant for real via a shared revokeMemberGrant helper (factored out of kickFromRoom, which now shares it too) and fires the matching member_left or invite_declined notification, telling the two cases apart from its own membership/invited lists rather than a separate verb. This also fixed a real, previously-unnoticed bug: declineInvite read a locally cached Room object handleRoomInvite never actually constructs, so a real decline under the current invite flow would have thrown ROOM_NOT_FOUND.
With this, the two-way membership operations (join, invite, leave, decline, kick) all revoke for real and are gated the same way. Remaining for P3.8: destroyRoom's own broadcastPatch retirement, a real replacement for createRoom's own room-existence broadcast (public/private rooms need to stay discoverable via list_rooms before anyone's joined -- see the earlier note above), the broader deliverToRoom/deliverLocallyAndBroadcast retirement for informational events (member_joined/member_left/member_status currently still ride the legacy mesh-wide "delivery" patch even where a directed alternative now exists), and agent register/update/offline (blocked on P4's presence work landing first).
PR #83 opened: destroyRoom now revokes every member's own grant for real via the same revokeMemberGrant helper, before its existing cleanup and room_delete broadcast.
With this, every membership-lifecycle operation that removes someone from a room (kick, leave, decline, destroy) revokes their real token, not just the CRDT record. Remaining for P3.8: a real replacement for createRoom's own room-existence broadcast (needs a lightweight discovery mechanism, not a quick removal -- public/private rooms must stay discoverable via list_rooms before anyone's joined), the broader deliverToRoom/deliverLocallyAndBroadcast retirement for informational events (member_joined/member_left/member_status/room_delete/invite_declined all still ride the legacy mesh-wide "delivery" patch even where each event's own audience is already directed), and agent register/update/offline (blocked on P4's presence work landing first). None of the three remaining pieces is a quick removal -- each needs its own design pass, matching every other piece of this phase so far.
Room-discovery replacement, initial design direction (2026-09-14) — grounded against the real Room schema and the existing presence-gossip precedent, not fully resolved.
Room(src/core/types.ts'sRoomSchema) carriesid(the room path),name,type,owner,createdAt,descriptionplus CRDT membership bookkeeping irrelevant to discovery -- the minimal tuple worth gossiping is{path, name, type, description},typefiltered topublic/privateonly (neversecret, matching the existing rule that only those two stay pre-join discoverable).The mechanism to reuse:
WireMeshTransportalready has exactly this shape once, for presence (reportPresenceAdvert,wire-mesh-transport.ts:383-395) -- a domain-qualified gossip extension key (presence/status) re-advertised on a timer viasendGossipUpdate, read back off a session's owndirectory: readonly DirectoryEntry[].#87(wire-mesh, already closed) established the identical pattern for delegable-grant candidates (capability-request/candidates). Aroom/hostedkey carrying each owner's own currently-owned public/private rooms is a third instance of the same convention, not a new mechanism:createRoom/destroyRoomupdate local "rooms I own" state, the next gossip re-advert cycle carries it, andlistRoomsmerges in any peer's advertised entries not already locally known (deduplicated by path) as lightweight, not-yet-joined discoverable rows.Not yet resolved, the concrete next step for whoever implements this:
reportPresenceAdvert's own scoping is deliberately narrow -- it reads only the directory entry for the one session's own authenticated peer, never any other device a multi-hop directory might mention. Room discovery needs the opposite: every currently-known device's advertised hosted-rooms list, mesh-wide, not just directly-connected peers. WhetherWireMeshTransport/MeshTransport(the legacy coordinator-oriented portWireMeshTransportitself adapts underneath,transport.ts:142) already exposes an all-peers directory aggregation anywhere, or whether one needs to be added, wasn't checked before this session's context ran out on this investigation -- check that first, since it determines whether this is a small addition (aggregation already exists) or needs its own transport-layer method first.Checked (2026-09-14): no all-peers directory aggregation exists anywhere in agent-comms today -- confirmed, not just unconfirmed.
listAgents(agent-registry.ts:140), the closest existing precedent for "discover everything currently known mesh-wide," still reads entirely fromthis.deps.agents, a plain local Map kept in sync only by the legacybroadcastPatch/CRDT-replication mechanism (agent register/update/offline) -- the exact mechanism P3.8's own remaining scope already says is "blocked on P4's gossip-based presence work landing first." So room-discovery isn't a standalone gap: it needs the identical underlying prerequisite agent-discovery-via-gossip already needs and doesn't have -- a real mesh-wide gossip directory aggregation inWireMeshTransport, not per-sessionreportPresenceAdvert-style single-peer scoping. Practical conclusion: sequence room-discovery's own implementation after P4's gossip-based presence work lands, since building it first would mean either duplicating whatever aggregation mechanism P4 ends up building, or building a second one only room-discovery uses. Bothroom/hosted(this item) andagent register/update/offline's own retirement should reuse the same aggregation primitive once P4 builds it, not invent two.Room-discovery's prerequisite aggregation built and merged (2026-09-16, PR #130):
WireMeshTransport.listKnownDevices(). Merges every live session's own peer-advert directory into one device-id-keyed map, mesh-wide, not just directly-connected peers -- the exact gap the last update above identified as missing before room-discovery (or agent register/update/offline's own retirement) could proceed.Write half of room-discovery landed too (2026-09-16, PR #131):
room/hostedgossip.WireMeshTransport's presence-readvertise mechanism generalised intoreadvertiseGossip, which now also carries this side's own currently-hosted public/private rooms via an optionalgetHostedRoomscallback, the same pull-not-push shapegetCurrentPresencealready established. A peer'slistKnownDevices()can now read another device's hosted-rooms advert directly underroom/hosted.Still remaining for P3.8's room-discovery piece: wiring
getHostedRoomsat thebridge-mesh.ts/mesh-store.tsconstruction site (readingRoomLifecycle's own locally-owned public/private rooms), and merging a gossip-discovered room intolistRooms's own return value -- deliberately deferred, since a gossip-discovered, not-yet-joined room needs its own synthesized shape decided (a fullRoomobject carries membership/grant fields a lightweight advert doesn't have), not just a mechanical splice into the existingroomsMap.createRoom's ownbroadcastPatchstays in place until the gossip-based replacement is wired and verified equivalent -- not removed in the same change that adds the new mechanism, matching how every other P3 piece added its real mechanism before retiring the legacy one.Also still remaining, noted but not picked up here:
agent register/update/offline's own retirement, which the last update already flagged as needing the identicallistKnownDevices()prerequisite -- now unblocked, but needs its own design pass for whatAgentIdentityfields (name, tags, visibility, pid) get gossiped versus what stays CRDT-only, since onlypresence/statusis gossiped today.PR #132 merged (2026-09-16): the gossip mechanism now carries real room data end to end.
MeshStore.hostedRoomsreads this store's own currently-owned public/private rooms out of the existingroomsMap, andbridge-mesh.tswires it intoWireMeshTransport'sgetHostedRoomscallback -- the same construction siteselfStatusalready feedsgetCurrentPresencethrough.createRoom's ownroom_upsertbroadcast stays untouched.What's left for room-discovery, concretely: merging a gossip-discovered room (read via
listKnownDevices()'sroom/hostedadvert) intolistRooms's own return value, for a peer that hasn't joined yet. This is the one piece still deliberately deferred -- it needs a decision on a not-yet-joined room's synthesized shape (a fullRoomcarries membership/grant fields a lightweight advert doesn't have:members,invited,memberJoins, etc.), not a mechanical splice. Once that lands and is verified equivalent to whatroom_upsertbroadcast already provides,createRoom's own broadcast can retire.PR #138 merged (2026-09-16): room-discovery's read side is real.
listRoomsnow merges gossip-discovered rooms. A public/private room heard viaroom/hostedgossip (#131/#132) but never locally known is surfaced as a display-only placeholder (real id/name/type/owner/description, empty membership fields) mixed intolistRooms' existingRoom[]result; a room already known locally always wins over its own gossip advert.MeshTransportgained an optionallistKnownDevicesmethod soRoomLifecyclereads through the existing abstractrequireTransport()port rather than depending on the concreteWireMeshTransporttype. A real bug (extracting the method before calling it, dropping itsthisbinding) was caught by running the full test suite before this ever reached CI, not just the new test file.Room-discovery is now complete end to end: write side (#131/#132) and read side (#138) both merged and tested.
createRoom's ownroom_upsartbroadcast retirement is the one piece still deliberately left for later -- it needs production verification that the gossip-based picture is equivalent before removing the legacy mechanism, matching how every other P3 piece added its real mechanism before retiring the old one, not in the same change.What's left in P3.8 overall, unchanged from earlier updates: the broader
deliverToRoom/deliverLocallyAndBroadcastretirement for informational events (member_joined/member_left/member_status/room_delete/invite_declinedstill ride the legacy mesh-wide "delivery" patch even where each event's own audience is already directed), andagent register/update/offline's own retirement (needs a design pass on whichAgentIdentityfields get gossiped versus stay CRDT-only).PR #139 merged (2026-09-16): agent-discovery's write and read sides are both real now, mirroring room-discovery (#131/#132/#138) closely.
WireMeshTransport's gossip tick gained a third optional source (getSelfAgentAdvert), gossiping name/harness/cwd/pid/startedAt/tags/subscribedRooms underagent/self(status reused from the existingpresence/statuskey rather than duplicated).MeshStore.selfAgentAdvertwithholds the advert entirely for anything other than "visible" -- gossip reaches every connected peer regardless of mesh-approval status, so a hidden or ghost agent never advertises this way.AgentRegistry.listAgentsmerges in a gossip-discovered agent not otherwise locally known, as a display-only placeholder, never merged into the local agents map, always shadowed by a real local record.Both halves of P3.8's own room-discovery and agent-discovery gossip mechanisms are now fully built. What remains is the actual retirement of each legacy CRDT broadcast (
createRoom'sroom_upsert,agent register/update/offline) -- deliberately not done here, since each needs production verification that the gossip-based picture is equivalent before removing the mechanism it replaces, matching how every other piece of this phase added its real mechanism first and retired the old one as a separate, later step. The broaderdeliverToRoom/deliverLocallyAndBroadcastretirement for informational events is the one piece of P3.8 with no gossip-based replacement designed yet.PR #140 merged (2026-09-16): the informational-event broadcast retirement now has a real mechanism, at least for deliverToRoom's own fan-out.
deliverToRoomno longer ridesdeliverLocallyAndBroadcast's mesh-widebroadcastPatchper member -- it fires local delivery directly for this store's own agent and sends a real, wire-authenticatedroom.notify(a new agent-comms-only verb, no wire-mesh spec change needed) to every other member, matchingroom.send/room.read's own established directed-fan-out shape.member_status,member_joined,name_changed, and every other eventdeliverToRoomcarries now goes out this way.What's genuinely left in #48:
deliverLocallyAndBroadcastitself is untouched and still backs every OTHER caller --federation-bridge.ts's own federated-room notifications,room-lifecycle.ts's own invite/join-related deliveries, andemitDeliveryStatus. Converting those is its own separate, still-open slice of the same retirement (each has its own audience-shape question worth its own look, not a mechanical find-and-replace). Beyond that, the two items already tracked in earlier updates remain: retiringcreateRoom's ownroom_upsertbroadcast now that room-discovery's gossip mechanism exists (needs production verification first), andagent register/update/offline's own retirement (needs a design pass on whichAgentIdentityfields get gossiped).PR #141 merged (2026-09-16): two more
deliverLocallyAndBroadcastcall sites indelivery-engine.tsconverted to directedroom.notify.notifyRoomsOfNameChange's own trailing self-confirmation delivery (traced everyupdateAgentcaller and confirmed the renamed agent can genuinely be remote -- the web console'srename_agentaction takes an arbitrary agent id from the directory, not just this store's own peer) now addresses the renamed agent via the implicit DM path when remote, falling back to plain local queueing for a real self-rename.emitDeliveryStatus(the delivery-receipt notification back to a message's sender, who can obviously be remote) now resolves the message's own room-path or DM-path via the already-existingfindMessageLocation(previously only used bymarkRead) and sends the receipt directly. Both share a new privatedeliverToMemberhelper, whichdeliverToRoomwas also refactored to use instead of duplicating its per-member body.findMessageSenderwas removed as dead code oncefindMessageLocationcovered its one remaining caller.What's genuinely left in #48, narrowed further:
federation-bridge.ts's own federated-room-message fan-out (onRoomMessage, loops over federated room members) androom-lifecycle.ts's post-join member-list delivery (joinRoom's ownroom_membersevent to the joining agent, who -- perjoinRoom's own doc comment about convergence/admin bookkeeping joining on behalf of a different agentId -- can also be remote) are the only twodeliverLocallyAndBroadcastcall sites left uncontverted, each still worth its own focused look at its audience shape before converting. Beyond that, the two items already tracked in earlier updates remain unchanged: retiringcreateRoom's ownroom_upsertbroadcast now that room-discovery's gossip mechanism exists (needs production verification first), andagent register/update/offline's own retirement (needs a design pass on whichAgentIdentityfields get gossiped).PR #142 merged (2026-09-16): the last two
deliverLocallyAndBroadcastcall sites are converted. Every mesh-wide broadcast this issue set out to retire for informational/message delivery is now gone.federation-bridge.ts's ownonRoomMessagelocal fan-out now calls a newDeliveryEngine.deliverRoomMessageToMemberper real local member (skippingfed:-prefixed shadow members entirely, per the same filtering conventiongetFederatedRoomMembershipsalready used -- they have no addressable mesh device of their own,federation.ts's own link forwarding is what reaches the real remote participant).room-lifecycle.ts's own post-joinroom_membersdelivery now callsdeliverToMemberdirectly (made public onDeliveryEnginefor this second external caller). A real regression was found and fixed along the way:room-send-retry-queue.test.ts's exact-total-count assertion broke oncejoinRoom's own new directedroom.notifybecame a second, legitimately-queued retry for the same member -- fixed by asserting on the specific message's own retry count rather than a brittle total.deliverLocallyAndBroadcastitself is now unused in production code (only the generic publicdeliver()CommsStore method still calls it, which remains the documented entry point for a caller that wants the legacy broadcast semantics). The directed-delivery retirement this issue tracked (member_joined/member_left/member_status/room_delete/invite_declined/room_message/name_changed/delivery_status/room_members, across deliverToRoom, notifyRoomsOfNameChange, emitDeliveryStatus, onRoomMessage, and joinRoom) is complete.What's left in #48, unchanged from earlier updates: retiring
createRoom's ownroom_upsertbroadcast now that room-discovery's gossip mechanism exists (needs production verification first), andagent register/update/offline's own retirement (needs a design pass on whichAgentIdentityfields get gossiped versus stay CRDT-only).Correction (2026-09-16): both remaining items above are resolved by correcting the framing, not by further implementation. This closes #48.
The directed-delivery retirement completed in PRs #140-#142 was correct specifically because a
deliverLocallyAndBroadcastevent's true audience (a specific room's members) is bounded and narrower than "every mesh peer" -- broadcasting it mesh-wide was a genuine mistargeting bug.agent_upsert/agent_offlineandroom_upsertare structurally different: their true audience genuinely is every mesh peer (any peer'slist_agents/list_roomsmay legitimately show any agent or any public/private room), so mesh-wide propagation is the correct design for them, not a retirement candidate.Checked directly against
delivery-engine.ts'sapplyPatch:agent_upsertcarries real CRDT-convergence semantics gossip has no equivalent for today -- aversionfield gating stale-copy rejection, plus a concurrent-mutation tie-break that unionssubscribedRoomswhen two peers bump the same base version independently.listAgents/listRooms' own gossip-merge (#131/#132/#138/#139) deliberately only synthesizes a placeholder for an entity not already locally known, and never touches the realagents/roomsMap for one that already is -- that's by design in every one of those PRs' own doc comments, not an oversight to fix. Rebuilding gossip to carry real versioned merge semantics just to replace an already-correct broadcast would be new machinery solving a problem that doesn't exist, and would trade push/low-latency/correctly-converging updates for staleness up to one gossip interval -- a real regression, not an improvement.agent_offline's coordinator-driven stale-peer-probe path (stale-agent-checker.ts) makes the same point from a different angle: it's a third-party declaration ("the coordinator's own PID probe says peer X is dead"), which has no gossip equivalent at all -- gossip is inherently self-asserted, so nothing but a broadcast can carry a third party's claim about someone else's liveness.Gossip's role stays exactly what it was already built to be: fast, cheap discovery of an entity this store hasn't yet been told about via the mesh's own real-time replication, complementing
agent_upsert/room_upsert's broadcast, not replacing it.Every other piece of #48's own scope (two-way membership-lifecycle revocation across join/invite/leave/decline/kick/destroy, and the full directed-delivery retirement for informational events) is done, merged, and tested. Closing.