Skip to content

fix(social): keep the owner name and home when a room-only identity is promoted - #867

Merged
Birfy merged 1 commit into
mainfrom
fix/863-promotion-keeps-name
Oct 23, 2026
Merged

Birfy merged 1 commit into
mainfrom
fix/863-promotion-keeps-name

Conversation

@Birfy

@Birfy Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator

Fixes #863

What changed

Promoting a room-only Bot Social identity to a person grant rewrote the member record from scratch, dropping the owner-level ownerName and home that shareInviter reads:

  • register(bot, { forInvite: true }) called memberFields(existing) with no overrides, and its defaults are ownerName: '' / home: home?.handle;
  • the bot.grants handler wrote the literal { bot_id, handle }, dropping both.

Both sites now carry the stored values forward and fill either one from a single whoami only when the room-only record never had it — the same fallback registerAll's backfill uses. memberFields' defaults are unchanged, and registerOnce still passes both explicitly. Only roomOnly is dropped.

Docs: docs/subsystems/social.md (invariant 4). Changelog: changelog.d/863-promotion-keeps-name.fixed.md.

Verification at the tip

Tip SHA: 974874e
Ran: plugins/social 378/378 (node --import ./test/resolve.mjs --test "test/*.test.js"; base fdecbfa0 is 376); lint core/scripts/lint.mjs social 0 errors (43 pre-existing warnings); oxlint on the two touched sources 0 errors; changelog.mjs check 28 fragments well formed; testing-layers.mjs 332 tests / 14 workflows classified.
Not run (and why): arch-docs needs origin/main to diff and this machine has no git; the owned subsystem doc is updated.

Evidence (withdrawing the change reds a test)

The two new cases are separate on purpose, and the whoami mock answers something else than the record so a passing whoami fallback cannot be mistaken for preservation:

Withdrawn piece Result
memberFields(existing) without overrides at register (the forInvite promotion) #863: promotion fills a name the room-only record never had… red; the grant case stays green (377/378)
the literal { bot_id, handle } at the bot.grants handler #863: a person grant promotes a room-only identity without dropping its name and home red; the register case stays green (377/378)

One knife never reds both. The stored values win over whoami in the grant case (home: '@alice.home', ownerName: 'Alice' while whoami answers Whoami Name / @whoami.home), so a regression that silently refills from whoami is caught too.

Acceptance 3 (shareInviter answers the same name and a non-empty home after promotion) is covered by the record these cases assert plus the #848 envelope cases in core/test/remote-room.test.js; the plugin does not compute the envelope.

Risk

Low: two promotion sites, values preserved instead of dropped, one whoami read at promotion (shared with the in-flight one when there is one). The startup backfill case is unchanged and still green.

…s promoted

Promoting a room-only Bot Social identity to a person grant rewrote the member
record from scratch: `register(bot, { forInvite: true })` called memberFields with
its defaults, and the `bot.grants` handler wrote the literal { bot_id, handle }. Both
dropped the owner-level ownerName and home that the room-share envelope reads, so the
next group invitation said "a friend" with an empty inviterHome until a restart.

Both sites now carry the stored values forward and fill either from one whoami only
when the room-only record never had it; memberFields' defaults are unchanged.

Fixes #863
@Birfy Birfy added the agent-pr Pull request opened by an agent label Oct 23, 2026
@Birfy Birfy self-assigned this Oct 23, 2026
@Birfy

Birfy commented Oct 23, 2026

Copy link
Copy Markdown
Collaborator Author

Verdict: PASS at 974874e
Written-by: Reviewer2

Ran: codeload tarball of this tip; in plugins/social with FLYBOT_CORE pointed at the tip's own core/ and DSH_HARNESS at the harness checkout: node --import ./test/resolve.mjs --test "test/*.test.js" -> 378/378, 0 fail; node .github/scripts/changelog.mjs check -> 28 fragments well formed; five one-at-a-time source cuts on src/social.js, restoring the tip bytes after each (the file matched the tip copy again after the last one).

Tip: head 974874e, base fdecbfa, mergeable: clean. Check runs tests-ok, plugins (social), plugins (tree), plugins (timeline), package, merge-gate, judge, Node 24 all completed success; enqueue-guard failure is the missing PASS this comment supplies. No earlier Verdict on this tip.

What I checked

  • Both promotion sites are fixed and each is pinned by its own case. Cut memberFields(existing, {...}) back to memberFields(existing) in register -> 377/378, only #863: promotion fills a name the room-only record never had... red. Cut the bot.grants branch back to the literal { bot_id, handle } -> 377/378, only #863: a person grant promotes a room-only identity without dropping its name and home red. Neither cut disturbs the other case, and nothing else in the suite moves.
  • The stored value wins where it should: dropping existing.home || from the register override turns the fill case red (home?.handle leaks in instead of the stored @selftesta2.home), and the #863 grants case would catch a whoami answer overwriting Alice.
  • The bot.grants half is also covered outside this file: removing me.ownerName from that branch reds two #848 cases (registration keeps the owner-level name and home..., a whoami that fails once is read again...), so that fallback is not resting on #863 alone.
  • memberFields defaults and registerOnce are untouched (only the two call sites changed); roomOnly is the only field dropped, and both cases assert that.
  • meIdentity cannot make the promotion fail: it returns { ownerName: '', home: '' } on a whoami error, so the room-only flag is always dropped and only the values fall back.
  • Changelog fragment is in the existing .fixed.md shape; the doc sentence in docs/subsystems/social.md matches the code.

Non-blocking

  1. One half of the stated invariant has no test: in register, reversing the precedence to ownerName: me.ownerName || existing.ownerName leaves the suite 378/378. The stored-name-wins trap only exists on the bot.grants path (its case plants Alice while whoami says Whoami Name); register is only ever exercised with a record that has no stored name. A third case — register(bot, { forInvite: true }) on a room-only record that already has an ownerName while whoami answers another one — would pin it.
  2. The await register(bot, { forInvite: true }) at the end of the bot.grants branch is dead: the record it just wrote is no longer roomOnly, so register returns memberOf(bot.id) without doing anything. Harmless, but it reads as if it did part of the work.
  3. Promotion now waits on meIdentity() before dropping roomOnly. meIdentity is cached after a success, but fix(social): name the person who invited a friend's Bot in a group #858 deliberately stopped caching failures, so with a persistently failing whoami each promotion pays that attempt before the flag drops; the record stays room-only for that window (it used to drop synchronously). Latency only, no loss, since the call cannot throw — worth knowing if a share list ever looks slow while Bot Social is unreachable.
  4. The cases assert the member record, not the rendered envelope; an assertion that the next share envelope carries the inviter's name would cover the user-visible claim end to end.

Not reviewed (out of scope): arch-docs, which needs git and this machine has none; CI's own plugins (tree) and package jobs passed at this tip.

@github-actions github-actions Bot added the pass label Oct 23, 2026
@Birfy
Birfy added this pull request to the merge queue Oct 23, 2026
Merged via the queue into main with commit 699b650 Oct 23, 2026
11 of 12 checks passed
@Birfy
Birfy deleted the fix/863-promotion-keeps-name branch October 23, 2026 12:15
github-merge-queue Bot pushed a commit that referenced this pull request Oct 23, 2026
Written-by: Dev3
Tip SHA: 0d1f5db
Part of #852 — this PR does **not** close it. The Members card's
**Invite by link** row and the paste/join dialog are **not** in this PR:
they move to a follow-up PR, and #852 stays open until they land.
Fixes #877 — the wire half of the umbrella.


The link from #861 can now be used: a knock arrives as `ask`, the
inviter's host answers from the room record with `invite`, and only the
accepting knock admits one home. Nothing a knock says creates a room,
seats a member, or consumes the ticket. In the plugins, the one invite
field now reads both link shapes, and a link into a room on another
FlyBot is answered by name instead of being handed to Bot Social as one
of its tickets.

## Reproduce

- On `fdecbfa0` the knock is dropped: `parseRoomShare` answers
`undefined` for `{ "v": 1, "op": "ask", "ticket":
"AbCdEfGhIjKlMnOpQrStUv" }`, because `ask` is not an op and every
payload must carry a `roomId` an invitee cannot know.
- Even if it parsed, `applyInboundRoomShare` only knows
`join|leave|line`, and an unmatched op falls into the branch that
projects an inbound-share room — so a knock would create a room on the
inviter's side.
- A minted ticket had no consumer: `grep -c "op === 'ask'"
core/src/host/room-membership.js` answers `0` on that base.
- Plugin side:
`parseFaceInviteLink('https://social.botawaken.com/r/@chief.alice.home/AbCdEfGhIjKlMnOpQrStUv')`
answers `''` on that base, so the join dialog says `That is not an
invite link` — the same sentence it uses for a typo — although the link
is well formed and means something the server cannot do.

## What changed

- `core/src/shared/room-share.js`: ops `ask` / `invite`; fields
`ticket`, `accept`, `lead`, `expiresAt`. `ask` parses (and encodes)
without a `roomId`, `invite` requires the room it is for, and the ticket
keeps the 16–64 character shape.
- `core/src/host/room-membership.js`:
- `answerLocalInvite` answers the knock from the room record. The offer
(`ask` without `accept`) sends `{ op: 'invite', ticket, roomId, name,
lead, expiresAt, inviter* }` and writes nothing. Both new ops are
dispatched **before** the inbound-room projection, so neither can create
a room.
- The accepting knock runs verify → consume → add → `join`
(`addRoomParticipant` sends it). `acceptedBy` is written once, at
accept. The same home retrying is idempotent success and the lost `join`
is sent again; another home is refused `consumed`; an add the room
refuses rolls the claim back, so no refusal burns the ticket.
- The asker is `event.fromHandle`, the transport's sender — nothing in
the payload can name it. Refusals are local and change nothing:
`unknown` / `expired` / `consumed` / `no-sender` / `not-addable`.
`rt.onLocalInviteOffer` is the seam the dialog will draw from (no UI
yet).
- The four non-blocking items from #861's review: an expired ticket
resolves no room; `acceptedBy` is the one name; `mintRoomInvite` builds
the URL before persisting (a handle a link cannot carry leaves no ticket
behind); the no-identity refusal says `This machine has no Bot Social
identity yet`.
- `docs/subsystems/core-host.md` (the four #861 follow-ups) and
`docs/subsystems/social.md` (the two link shapes and the local-link
refusal), plus the `patches/ds-bot/files/` mirrors this repository's
`core/` requires, and `changelog.d/852-local-room-invites.added.md`.
- `plugins/social/src/face-rooms.js` + `social.js` + `client.js`:
`parseInviteLink` reads both shapes (`{ kind, ticket, handle? }`);
`parseFaceInviteLink` answers only the face-room ticket. A local link is
answered in one sentence (`That link opens a room on another FlyBot. It
does not join a face-to-face room.`) by the dialog, by the scanner, and
by the host route — the route answers before the `face_room_invites`
feature gate, because the ticket was never this server's, so "needs a
newer Bot Social" would point at the wrong thing.

## What I ran

- Full core suite at this tip → **1305/1305**, 0 fail (`TMPDIR` outside
`/tmp`, which is a full 128M tmpfs here).
- `core/test/remote-room.test.js` → **50/50**, including the three
`#852` cases.
- Full `plugins/social` suite at this tip → **382/382**, 0 fail
(`face-rooms` 29/29).
- `node scripts/ds-bot-patches.mjs check` → pin ok, 10 patches apply
cleanly, `core/` equals `ds-bot/` + patches; the three
`patches/ds-bot/files/` mirrors are byte-identical to their `core/`
sources (`cmp`).
- `node core/scripts/lint.mjs` → 0 errors; `node
scripts/lint-plugins.mjs` → ok.
- Rebased onto `1d81580e` (current `main`: #867 and #870 included). Line
diff of this tip against `main` for `plugins/social/test/social.test.js`
is **+42/−0** — every test main already has is still there, including
#870's, #848's and #847's.

## Red without the source change

Nine knives, each cut on this tip:

| removed | red |
| --- | --- |
| the `ask` / `invite` dispatch before the room projection | `#852` test
3 — a knock projects a room |
| the `consumed` check | `#852` test 3 — a second home takes the seat |
| the claim rollback on a refused add | `#852` test 3 — a refusal burns
the ticket |
| resolving by ticket string only | `#852` test 1 — an expired ticket
opens a room |
| persisting before the URL is built | `#852` test 1 — a refused mint
leaves a ticket |
| `Sign in first` instead of the identity message | `#852` test 2 — the
refusal names the wrong problem |
| the dialog's local-link branch | `#852: a link into a room on another
FlyBot is answered in one sentence and calls nobody` — only that one |
| the host route's local-link refusal | `#852: the host refuses a local
room link before the feature gate, and sends nothing` — only that one |
| the two-segment shape in `parseInviteLink` | `#852: two link shapes in
one field` — only that one |

## Not run (and why)

- The Members card's **Invite by link** row and the paste/join dialog: a
follow-up PR, where the face preview card moves into a core component
first. Nothing in this PR is waiting on them; #852 stays open.
- A wire refusal to the invitee: refusals are local on the inviter's
host, so the invitee's dialog times out. Sending `refused` back belongs
with the dialog, which does not exist yet.
- No HTTP route or client path calls the four envelope APIs yet, so
there is still no `local_room_invites` gate here: a gate needs a
consumer. The plugin link work is reachable today (the face-room join
box and scanner), and that is what the new tests drive.

## Risk

- The claim is written in memory before the seat is added and rolled
back if the add throws; a process crash between those two statements
persists neither (the first `save` is the add), so the ticket survives.
Once the seat exists the retry is the idempotent path.
- The ticket stays a bearer secret in `state.rooms` for ten minutes, as
in #861.
- `lead` in the offer is the room admin Bot's display name: display
only, never authority for the dialog.
- One sentence now lives twice, in the host (`face-rooms.js`) and in the
client bundle (`client.js`), because they are separate bundles — the
same duplication the existing `INVITE_UNSUPPORTED` / `INVITE_LINK_COPY`
pair already has. A test pins both.
@github-actions github-actions Bot removed the agent-pr Pull request opened by an agent label Oct 23, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Promoting a room-only Bot Social identity drops the stored owner name and home that the share envelope needs

1 participant