ADR-170: Human focus-group membership via username identity and a shared attend-groups crate¶
Context¶
attend-chat (ADR-120) is the human's seat on the signal bus, but focus-group
membership (ADR-118) is claude-only: _groups.yaml members are Claude Code
session UUIDs, liveness is judged against the per-session heartbeat sidecar
(ADR-129), and the only writer of the yaml is the attend binary acting for a
claude session. The chat TUI's slash registry advertises /join and /leave
as planned, and three deferred pieces all converge on them:
- Humans have no membership key. The chat user has no session UUID and no
heartbeat, so there is nothing to write into a
members:list — and a heartbeat-less member would immediately count as dead tolive_peer_countand be swept bycleanup_stale. - attend-chat has no write path. Its
groupsmodule is an explicitly read-only byte-mirror ofattend::groups— two hand-rolled YAML parsers kept in sync by golden tests, with the module docs deferring "a shared I/O layer" to the/joinwrite path. - Membership is invisible on human chips. The chip renderer looks up group glyphs by session UUID, which humans don't have.
Separately, agent-side send validation (attend send --focus) counts a member
as live only if it appears in PeerSensor::live_session_ids — a claude-process
scan. A human member would never count, so a group whose only live member is a
human would reject agent sends with "no live peers".
Channel hygiene has a matching blind spot. The chat's channel bar renders
every @name/ directory on disk, but attend's cleanup_stale iterates
_groups.yaml entries — an orphan dir (no yaml entry at all) is invisible
to cleanup and renders in the bar forever. In practice stale test channels
accumulate and the human has no way to remove them from the surface where
they cause the confusion.
Decision¶
A human's membership identity is their sanitized username (e.g. aaron,
via agent_identity::sanitize_id_component($USER)). One entry per human,
regardless of terminal or cwd — deliberately matching the chat registry's
existing human-dedupe rule (the same person in two terminals is one identity,
where two claudes in two cwds are two).
Human liveness rides the existing heartbeat sidecar. While attend-chat
runs, it touches heartbeat/<username> on its existing 5-second refresh tick.
No new liveness mechanism and no yaml format change: members: lists now mix
session UUIDs and usernames, and every consumer already judges members by
heartbeat freshness (live_peer_count, cleanup_stale), so human members age
out after DEFAULT_GRACE exactly like abandoned claude sessions. attend-chat
does not clear the heartbeat on exit — a second chat instance for the same
user may still be running, and the 90s grace self-corrects.
Group I/O is extracted into a shared attend-groups workspace crate,
following the pattern set by attend-heartbeat and attend-instances. The
crate owns GroupEntry, Groups (join/leave/pin/dissolve/cleanup and the
yaml read-modify-write), validate_group_name, and the parse/serialize pair.
attend keeps its attend-specific pieces (the ADR-124 @open migration);
attend-chat's mirror parser and the cross-crate golden-drift tests collapse
into the shared crate's own tests.
agent-side liveness validation gains a heartbeat fallback. attend send
--focus counts a member live if it is a live claude session or its
heartbeat is fresh — making a human-only group a valid send target. This
deliberately loosens the claude-side check too: a session whose claude died
but whose attend still heartbeats now counts as live, consistent with the
crate's member_alive philosophy (no attend, no mesh participation — and the
converse). The chat-side gate mirrors the agent side's self-exclusion: the
sender's own membership (now heartbeat-backed for humans) never counts toward
"live peers", or a solo human's send would validate against their own
heartbeat and sit unread.
The TUI wires the commands. SlashOutcome grows effect variants; dispatch
stays IO-free (parse + validate only) and the key handlers execute effects:
/join <group>//leave <group>call the sharedGroupswith the username identity;/clearempties the message buffer (display-only)./dissolve <group>removes a channel entirely — yaml entry and@dir, including orphan dirs the yaml doesn't know about. Chat-side it carries a live-member guard the CLI'sattend focus dissolvedoes not: a group with heartbeat-fresh members refuses to dissolve, since from the TUI this is a hygiene action and should not yank a channel out from under active peers./channelslists every channel withlive/totalmember counts in the status row (IRC/listshaped) —0/0 liveis the tell for/dissolvefodder./purge [group]deletes a channel's on-disk signal history (default: the base channel's_broadcast/), keeping a heartbeat-grace tail so nothing a peer's sensor may be mid-scan on is deleted. This is a deliberate operator override of ADR-136's durability default — that ADR forbids automatic age-reaping; an explicit human purge of a named channel is a different act, and the sensors already tolerate signal deletion (the project-liveness cleanup andattend cleanup --nuke-allpredate this). Membership and the channel itself survive a purge, unlike/dissolve. When a per-session consumption checkpoint exists (the drain-verb work being specced against ADR-136), purge should tighten to also refuse signals unconsumed by a live session; the grace tail then becomes the fallback for non-live consumers.
Human chips look up group glyphs by username so membership renders the same as it does for claudes.
cleanup_stale gains an orphan-dir sweep. After the member pass, @name/
dirs with no yaml entry and an mtime older than the heartbeat grace window are
removed — closing the accumulation path so stale channels stop outliving
their groups. Three guards defend concurrent joins, since create_dir_all on
a pre-existing orphan dir does not refresh its mtime: join saves its yaml
entry before touching the dir, the sweep re-reads the yaml immediately
before each removal, and the chat's group resolver falls back to the yaml
entry when the dir is missing (signal writers re-create their target dir), so
even the residual sub-millisecond race self-heals. Reserved names are never
swept — a lingering @open/ belongs to the ADR-124 migration, which moves
its signals into _broadcast/ rather than deleting them.
Consequences¶
Positive¶
- Humans become first-class group members with zero wire-format change — every existing consumer works unchanged because liveness was already heartbeat-shaped, not UUID-shaped.
- The two hand-rolled YAML parsers and their golden-mirror maintenance burden are replaced by one implementation with one test suite.
- Group lifecycle rules (empty-unpinned GC, stale sweeps) apply to humans for free; an abandoned chat session cannot pin a group open forever.
attend send --focusstops lying about human-only groups.- Stale channels become manageable from the surface where they confuse:
/channelsshows which are dead,/dissolveremoves them, and the orphan sweep stops the accumulation at the source.
Negative¶
- Usernames and session UUIDs share one namespace in
members:. Collision is implausible (UUIDs vs short login names) but the list is no longer homogeneous, and tooling that assumed "member = session UUID" must not reappear. - A username heartbeat conflates "some chat instance is running" with "this chat instance is running" — two instances for one user share a heartbeat by design, so per-instance presence for humans is out of scope.
- One more workspace crate to version and build.
Neutral¶
- attend-chat gains its first write responsibilities on the signal base (yaml read-modify-write and heartbeat touches), inheriting the same last-writer-wins races attend sessions already tolerate. The extraction hardened the write itself to keep that risk model honest: per-writer unique tmp names mean concurrent savers can no longer publish a torn hybrid file — the worst case is genuinely last-writer-wins, not corruption.
- The chat watcher still renders all groups regardless of membership; subscribed-group filtering remains future ADR-120 work — after this ADR, joining changes presence and addressability, not what the human sees.
Alternatives Considered¶
- Prefixed human member ids (
human:aaron) — would make the member kind explicit, but changes the yaml contract in both parsers, requires special-casing in every liveness check, and buys nothing the heartbeat doesn't already provide. Rejected for format churn without benefit. - Per-instance human identity (
aaron@kitty) — mirrors the wirefromfield, but splits one person into N members, contradicts the chat registry's human-dedupe rule, and makes/leaveambiguous about which instance leaves. Rejected: membership is about the person, not the seat. - attend-chat depends on the attend binary crate as a library — avoids a new crate but drags sensor/CLI machinery into the TUI build and inverts the dependency taxonomy the small shared crates established. Rejected.
- Keep duplicating: hand-roll a second yaml writer in attend-chat — the read-side mirror is already a documented maintenance hazard; a write-side mirror doubles the drift surface on the file both binaries mutate. Rejected; the golden tests exist precisely because this was fragile.
- Synthetic session files for humans — writing fake
~/.claude/sessions/*.jsonentries so humans traverse the claude discovery path. Rejected: pollutes Claude Code-owned state and misrepresents what a session is.