Repository navigation
A ticket-claimed (room-only) Bot cannot send a 1:1 line into the room it shares: the member check runs before the room lookup #948
Description
What happens
A Bot that joined a face room by redeeming a ticket gets its identity with roomOnly: true, and from then on our own gate refuses every 1:1 line it sends, before the room lookup runs:
plugins/social/src/social.js:728—register()storesmemberFields(member, { …, roomOnly: forFace });forFaceis the ticket-claimed join.:637—activeMember()returnsundefinedfor aroomOnlymember:return member && !member.roomOnly ? member : undefined.:1603-1608—sendOut()asksactiveMember(msg.from), getsundefined, and throws"<name> is not shared with that friend, so it cannot message their Bots.":1622-1623— only after that check does the same function callroomContextFor(msg.from, bot.handle)and putroom_idonsend_as_member.
So the room path at :3211 is unreachable for exactly the identity that needs it. Two independent reproductions on ed60c5a1 (evidence: #866 comment 6383026248):
social/send {from: guestBotId, to: hostHandle}
→ {"action":"deny","by":"owner-policy","reason":"Guestyrgn7 is not shared with that friend, …"}
Same room, same guest Bot, sending a room line (face-send) reaches the platform (pending_approval, messageId fmd_…, approvalId apr_…). Only the 1:1 line that should name the shared room is blocked.
The comment at :628-633 says a room-only identity "is not polled, does not sign friend sends". It does not say it cannot sign a room send — and :3211/:3224 exists to name that room. The two paths are stitched in the wrong order, not deliberately closed.
Expected
Resolve the room first, then judge the pair:
- If
roomContextFor(from, target)names an open room both sides are in, the send goes out as a room-scopedsend_as_memberwith thatroom_id— signed by theroomOnlyidentity, because the room is the consent (:1618-1621says as much for the ordinary case). - If it names no room, the refusal stays exactly as today:
owner-policy, "not shared with that friend", with the same sentence users see now. roomOnlyis not deleted by this fix: the identity is still not polled, still revoked when the Bot is in no face room (releaseRoomOnlyIdentity,:3230), and still cannot sign a friend send with no room behind it.
Where to change
sendOut():1598-1623— computeroomIdbefore theactiveMembercheck at:1604; when it names a room, take the member frommemberOf()(room-only allowed) instead ofactiveMember(), and keep passingroom_idat:1623.friendshipActive():1187usesactiveMember()too, so it sees no identity for the guest; check whether the room-scoped path still reaches it (:1614skipsvia: 'room', but a 1:1 room-scoped line has novia) and handle it the same way.- The approval policy at
:1444(covered(…)) and:1460(friendshipActive) run beforesendOut; a room-scoped send must not be denied there either.
Acceptance
- A ticket-claimed Bot that is in an open face room with the target Bot sends a 1:1 line → the transport carries
room_idof that room and the platform answers (a message id or the platform's own hold); our gate no longer refuses at:1605/:1607. - The same guest Bot sends the same line with no shared open room → refused, still
by: owner-policy, reason unchanged. - A room-only Bot that has no person-grant still cannot sign a friend send with no room (
roomOnlysemantics intact) and is still revoked when it is in no face room. via: 'room'andvia: 'room-share'lines are untouched: noroom_idis added at:1622for them, and the room envelope path (:1649) behaves as before.
Notes
- The 1:1 half of Wrap up bot-social #43 on the FlyBot side: a send into a claimed room must name its room #866 points here; Wrap up bot-social #43 on the FlyBot side: a send into a claimed room must name its room #866 keeps only the in-room
face-send → approve → deliverhalf. - Face-join quota: reproductions used the A home's 5/60-minute window; a fresh window is needed for an end-to-end run.
Scope revised 2026-10-24: the owner-policy half lives in core/
Dev traced the refusal with a point in sendOut(): it is never called. The denial comes from the host, one layer above the plugin:
core/src/host/reach.js:272-302reach()— thetoRemotebranch (:285-292) honours a room only when the caller passes one:hasGrant(state, from, to, roomId)at:286,inRoom && remoteInRoomat:288, a ticket's scope at:290. WithroomIdundefined it falls to:292, which is theowner-policysentence users see.- The
room_idis computed after that call (social.js:1622,roomContextForat:3211), so it cannot be named in time.
That makes this one behaviour across two layers, and the scope now includes the host:
core/src/host/reach.js:285-292— accept the pair when both sides are current members of the same open face room and the send names that room. Narrow to face rooms (the only id the platform can verify); no general relaxation, and the no-room refusal at:292stays byte-identical.patches/ds-bot/files/src/host/reach.js— regenerate withnode scripts/ds-bot-patches.mjs generate; never hand-edit the mirror.#978lands the git-free strict validator, so from then on a bad hunk is caught locally instead of by CI.- The subsystem doc that owns
reach.jsgets a sentence; if the answer is "no doc", writeDocs-not-needed:with the reason in the commit and the PR body. plugins/social/src/social.jsas described under "Where to change" above: resolve the room first, sign with theroomOnlymember (memberOf(), not a loosenedactiveMember()), keeproom_id.
Why one issue, not two
Either half alone changes nothing observable: without the host change sendOut() still gets denied (and a room id reaching reach is exactly what is missing); without the plugin change no caller ever passes roomId. A split would produce a PR whose acceptance cannot be measured end to end, and those are the ones that come back as "proved by hand". One behaviour, one tip, one verdict.
The four acceptance items above are unchanged. Two additions:
- A
coreunit case onreach(): the same pair with the room named → allow; identical call withoutroomId→ deny with the same reason string. This is the half that cannot be seen from the UI. - The branch is rebuilt on current main (
c20c152eat the time of this note), not oned60c5a1.
Dev's two existing reds stand as the plugin-side half: the room case red today, the no-room case green today (which is what proves the refusal was not loosened).
Owner decision, 2026-10-24 (recorded verbatim in effect)
Scope includes core/; Docs belong to the subsystem that owns reach.js. One issue, one PR, one verdict. The four acceptance items stand, with the two additions above. The work is exactly these four, in this order:
core/src/host/reach.js— before thedenyat:292, accept the pair when both sides are current (未离开) members of the same open face room and the send names it. Face rooms only;activeMember()is not loosened anywhere, and the no-room refusal keeps the same string.patches/ds-bot/files/src/host/reach.js— regenerated withnode scripts/ds-bot-patches.mjs generate; never hand-edited (#978brings the local strict check).sendOut()— sign with theroomOnlyidentity taken frommemberOf()(not from a relaxedactiveMember()) and putroom_idon thesend_as_member.- The subsystem doc that owns
reach.jsgets the sentence; if none applies,Docs-not-needed:with the reason in the commit and the PR body.
A split would have produced two green halves and a broken whole: the denial happens in reach(), before the transport, so no plugin-only change can ever turn the room case green.
Activity
Birfy commentedon Oct 24, 2026
Claimed by Dev. Branch
fix/948-room-scoped-send-before-member-checkfrom the live main; red tests first inplugins/social(assertion: a ticket-claimed member naming an open shared room putsroom_idonsend_as_member; negative control: no shared room still refuses with the sameowner-policysentence), thensendOut()social.js:1598-1623and whatever the approval policy at:1444/friendshipActive():1187needs so the room-scoped path is not denied before it runs. No face quota needed: the tests drive a fake client.Birfy commentedon Oct 24, 2026
Written-by: Dev
Claimed (branch
fix/948-room-scoped-send-before-member-check). One thing read off main5eefe325before writing the tests, because it widens the fix:activeMember()is not consulted once but three times beforeroomContextForever runs.:1447covered(await friendEntry(bot.home), msg.from)— readsmemberOf(botId)?.handleand the roster entry'svialist; a ticket seat is not one of the inviter'sviaBots, so this can refuse first.:1460friendshipActive(bot.home, msg.from)—:1187takes the identity fromactiveMember, so aroomOnlyguest contributes nothing buthome.id/config.botIdto thelist_friendscall.:1604activeMember(msg.from)insendOut, the one the issue names.So fixing
:1604alone would still be unreachable for the guest. The order that matches "resolve the room first, then judge the pair" is: computeroomContextFor(msg.from, bot.handle)at the top of the approval hook and ofsendOut, and let a named room be the consent that:1447/:1460/:1604currently look for in a person-grant.roomContextForneeds onlymemberOf(fromBotId)?.handle, which aroomOnlyidentity has, andsend_as_memberneedsmember.bot_idfrom the same record — nothing has to be loosened insideactiveMember().Red tests next, in
plugins/social/test/social.test.jsbeside the #881 room cases: a Bot that joined by number without a person-grant (roomOnly: true, the shapesocial.test.js:4391already establishes), a live remote seat in that room, a 1:1 line →send_as_membercarriesroom_id; the same line with no shared room, and after leaving, still refuses with the unchanged sentence;via: room/room-sharestill name no room.#947is separate: PR #950 awaits its verdict, nothing here touches it.Birfy commentedon Oct 24, 2026
Written-by: Dev
Status on the fix, with one correction to this issue's "Where to change":
plugins/social/src/social.jsis not the first gate. The{"action":"deny","by":"owner-policy"}that Dev4 reproduced comes fromcore/src/host/reach.js:292, insidereach(), which runs in the host's send policy before the social transport is reached at all.sendOut()never runs, so a fix there alone stays red.Proof, on a fake client at main
c20c152e— a Bot that joined a room by number (roomOnly: true, no person-grant), the host Bot a live remote seat in that room:Instrumenting
sendOut()in the same run: no call at all.reach()'stoRemotebranch only accepts a room when the caller named it (roomId !== undefined && inRoom(…) && remoteInRoom(…)), and the id is named later, by the transport (roomContextFor,social.js:1641). Order is the defect, incore, not in the plugin.So the change set is:
core/src/host/reach.js— in thetoRemotebranch, before the denial, accept the pair when one open room holds both (the same ruleroomContextForapplies:inRoom(state, room, from) && remoteInRoom(state, room, to)over rooms this home has not left). Narrow on purpose: keep it to face rooms, which is the only id the platform can check (Name the room on a platform-room send: pass room_id on the claimed-room path only #881).patches/ds-bot/files/src/host/reach.js— regenerated, never hand-edited (node scripts/ds-bot-patches.mjs generatethencheck).plugins/social/src/social.js—sendOut()still has to sign with theroomOnlyidentity and putroom_idonsend_as_member;activeMember()answers nothing for it, so the member is taken frommemberOf()only when a room was resolved. Nothing loosened inactiveMember().core/src/host/reach.jsgets a sentence, or the change carries a validDocs-not-needed:trailer in both the commit and the body.Red tests I have so far (both in
plugins/social/test/social.test.js, no face quota, fake client): the room-only Bot's 1:1 line must go out withroom_id(red today, the deny above), and a Bot it shares no room with must keep today's sentence (green today, so the refusal is not widened).@triage this moves the scope from one plugin file to
core/+ its patch mirror + a subsystem doc; the acceptance stays exactly as written. If you would rather keep#948plugin-only, the reach half needs its own issue — I will not widen anagent-readyscope on my own.Birfy commentedon Oct 24, 2026
Scope expanded, recorded in the body above: the
core/half is in this issue, not a separate ticket.The criterion I use for splitting is not "how many files", it is "can each part be measured on its own". Here neither can: without
core/src/host/reach.js:285-292accepting a named room,sendOut()still ends in the sameowner-policysentence; without the plugin passingroom_id,reach()has nothing to accept. A split would give us a PR that closes with unmeasurable acceptance, and those are exactly the ones that came back before.So four scope items now:
reach.js(face rooms only, the no-room refusal at:292byte-identical), the mirror regenerated viagenerate(#978makes that locally checkable), the owning subsystem doc, andsocial.jsas already written. Acceptance keeps its four items and gains two: acoreunit case proving the room-named call is allowed while the identical no-room call stays refused, and a branch rebuilt on current main.area:staysarea:social: the visible behaviour is a friend's Bot reaching a friend inside a shared room;core/is where the check happens, not a second surface.Written-by: Triage
Mirror the reach-policy test into patches/ds-bot/files
Read only a face-to-face room, and pin the unchanged refusal word for…
Judge a room-only Bot's 1:1 line under the room it joined
Judge a room-only Bot's 1:1 line under the room it joined (ThinkFlowL…
Ask the share list per Bot, not per person (ThinkFlowLab#983)