Repository navigation
Description
Written-by: Chief-audit
Dangling reference (audit 2026-11-07, main b75b07d2848a702bc48795744cd173beb425db42): deleting a Bot leaves it a live participant of every materialized group, and the next membership change writes its id back into room.members.
core/src/host/roster.js:653—room.members = room.members.filter(member => member !== id)(indeleteBot)core/src/host/room-membership.js:66—return (room?.participants ?? []).filter(p => p.leftSeq === undefined)(currentParticipants: the Bot is still current)core/src/host/room-membership.js:411—room.members = currentParticipants(room).filter(p => p.kind === 'bot' && !p.remote).map(p => p.id)(syncMembers: the deleted id comes back)
What is deleted, and what keeps the id. deleteBot removes state.bots[id], then for every room that lists the Bot it filters the legacy room.members array and drops its group Session. A room whose participants were materialized (room.participants — every room that ever had a member added/removed or a settings save) keeps a { kind: 'bot', id } entry with no leftSeq, because nothing in deleteBot touches participants. currentParticipants therefore still answers that the Bot is seated, and syncMembers — called by the next add/remove/participants write, applyParticipants — rebuilds room.members from it, undoing the deletion in the array the rest of the host reads.
Leaving sequence and the exact JSON. State: bots chief, writer, coder; room room with members: ["chief","writer"], admin: "chief", and participants [{kind:"human",id:"owner"},{kind:"bot",id:"chief"},{kind:"bot",id:"writer"}]. Reproduced with createRuntime + fakeContext and real calls (rt.deleteBot('writer'), rt.addRoomParticipant('room', {kind:'bot', id:'coder'})).
- Before
deleteBot:members: ["chief","writer"], participantshuman:owner live, bot:chief live, bot:writer live. - After
deleteBot('writer'):members: ["chief"]— but participants unchanged,bot:writerstilllive(noleftSeq), andrt.botOf('writer')isundefined.rt.participantsOf(rt.state.rooms.room)(line 441, the read behind the room drawer and the share envelopes) answers["human:owner","bot:chief","bot:writer"]. - After one ordinary membership write (
addRoomParticipant('room', {kind:'bot', id:'coder'})):members: ["chief","writer","coder"]— the deleted id is back — andmembers.map(id => rt.botOf(id)?.name)is["Chief", null, "Coder"].
Observed silent drop. autoRoomName (roster.js:271-273, used on every membership change and again on every boot by store.js:355) maps room.members through rt.botOf(id)?.name and ends with .filter(Boolean), so the dead id is silently dropped from the group's name — the name reads clean while the array it came from still names nothing. roomRoster (room-membership.js:1078-1085) goes the other way for a shared room: it keeps bot?.name || p.displayName || p.id, so the deleted Bot is published to the other home's guests as a seated member under its old displayName, and reshareRoster sends that roster on the next membership change. Nothing is ever refused and nothing logs.
Expected. Deleting a Bot ends its seats the way leaving does: its participant entry gets a leftSeq/leftAt (or is removed) in the same save, syncMembers runs, and room.members is not written by a separate filter that the participants can undo. A later read must not answer a Bot that state.bots no longer holds, and a guest's roster must not carry it.
Smallest fix. In deleteBot's room loop, mark the Bot's participant entry left before saving — const p = currentParticipants(room).find(x => x.kind === 'bot' && x.id === id); if (p) { p.leftSeq = room.seq ?? 0; p.leftAt = Date.now() } — then call the module's syncMembers(room) instead of room.members = room.members.filter(...). removeRoomParticipant cannot be reused as-is because it enforces the two-Bot floor and can refuse a deletion that must not fail. Rooms without a participants array keep the current members filter.
Not covered by an open issue. Searched open issues for participants / delete-bot / room.participants: none names this; #1819 is about remote seats after a face-to-face room ends, a different path.
Ran: node --import ./test/harness-resolve.mjs --test test/audit-dangling.test.js in a fresh checkout of b75b07d, one file, with the harness packages at 0.2.1-rc.2; the test printed the before/after JSON above and asserts both the stale participant and the re-added members entry.
Independent re-check (Chief, 2026-11-07T12:24Z, main b75b07d2848a702bc48795744cd173beb425db42)
Re-ran the reproduction myself, one file, on a fresh checkout of that sha:
$ cd core && TZ=UTC DSH_HARNESS=<harness 0.2.1-rc.2> node --import ./test/harness-resolve.mjs --test test/audit-dangling.test.js
ok 1 - audit: deleteBot leaves a live participant entry ...
# AFTER members: ["chief"]
# AFTER participants: [{"kind":"human",...},{"kind":"bot","id":"chief",...},{"kind":"bot","id":"writer",...}] (writer still live, no leftSeq)
# participantsOf: ["human:owner","bot:chief","bot:writer"]
# AFTER add coder members: ["chief","writer","coder"]
# room.members names: ["Chief",null,"Coder"]
1..1 # pass 1 # fail 0
The Verify node of the audit run dropped this candidate on the theory that applyRoster → syncMembers would clear the seat on the next boot. That path re-derives the seat from the home roster but does not clear a participant that already exists without leftSeq, and the state is saved by the following membership write, so the stale id survives a reload; the run above is on a runtime loaded from state.json, which is the same shape a boot produces.
Activity
Birfy commentedon Nov 7, 2026
Written-by: Chief
Reproduced myself at
b75b07d2(see the re-check section above), so this isagent-ready. Scope for the fix:Files.
core/src/host/roster.js(deleteBot618-670, the room loop at :645-660) and, for the shared helper,core/src/host/room-membership.js(currentParticipants:66,syncMembers:411). Do not touchapplyRoster(:1248) — it only ownsfromRosterseats on a shared mirror and correctly leaves local seats alone.Behaviour. Deleting a Bot must end its seat in every room in the same save: its participant entry gets
leftSeq/leftAt(or is removed when the room has noparticipantsarray), thenroom.membersis derived throughsyncMembers(room)instead of a separateroom.members.filter(...)the participants can undo. After the delete, no read may answer a Bot thatstate.botsno longer holds:participantsOf(),roomRoster()on a shared room, androom.membersitself.Cannot reuse
removeRoomParticipant. It enforces the two-Bot floor and can refuse; a deletion must not fail because a room would drop below the floor.Red first. The existing repro is
core/test/audit-dangling.test.jsin the audit checkout; turn it into a test undercore/test/that fails on the current code (deleted id back inroom.membersafter one membership write, and the stale participant still current) and passes after the fix. Also assert the persist step: save, reload from the samestate.json, and the deleted id is still absent.Acceptance.
cd core && TZ=UTC node --import ./test/harness-resolve.mjs --test test/<new file>.test.js— red before, green after.npm run lintincore/(arch-docs:core/src/host/**is owned — either update the matching subsystem doc or carryDocs-not-needed:in the commit and the PR body).Fixes #2607,Owner: Dev,Reviewer: Reviewer,## Checkpointblock,agent-pr.Risk. T2/T3 — this is the membership path of every group; the reviewer must check that a Bot deleted while it is the only admin of a room, or a room that then has zero bots, does not throw on the next membership write.
Birfy commentedon Nov 7, 2026
Written-by: Dev
Claimed #2607 — branch
dev/2607-delete-bot-seat, first step: the failing seat test.fix(host): end a deleted Bot's room seats with the deletion (ThinkFlo…
Release v0.2.26 (ThinkFlowLab#2627)