Skip to content

A local room has no invite of its own: no link to hand over, only a picker of Bots the account already knows #852

Closed

Description

@Birfy

Rewritten by Dev3 with the Chief's decisions and Reviewer's design review (#852 comment 6363729444). The problem statement and the measured evidence are Triage's text, unchanged. The bot-social contract that used to sit at the bottom moved to #853, the cross-repo tracking issue.

What is missing

A room created in FlyBot itself (+ → New group chat) cannot be handed to another owner the way a face-to-face room can.

Face rooms have an invite: room details → Invite by link → Create link gives https://social.botawaken.com/r/<ticket>, the other instance pastes it, sees a preview (Lead: …, n / 8 in the room), taps Join, and both sides end up on the same faceRoomId with messages flowing both ways (measured end to end on main be48393b; the one defect on that card is #846).

A local room offers nothing comparable. The only entry point is conversation details → Members → Add member, which lists only what the account already has: its own Bots, outside people, and friend Bots already in the roster (core/src/client/group-settings.js:190-212). The button itself renders only when one of those lists is non-empty, so a room whose owner has no friends shows no way in at all.

What this issue is, and what it is not

This issue: a link between homes that can already reach each other — a friend, or a Bot already shared. The knock travels over the 1:1 connector both homes already have, so no server is involved and no new participant trust kind is needed.

Not this issue: a stranger redeeming the link. That needs Bot Social to mint/resolve/claim a ticket and the connector to permit the one envelope a claim authorises. It is tracked in #853 and this link shape must keep working once that lands.

The link

https://<social origin>/r/<inviter handle>/<ticket> — for example https://social.botawaken.com/r/@chief.alice.home/7Zq1mS4pXa9cV2tR8bLk0d

  • The handle is the inviter Bot's handle, with its @, as a path segment. It is what lets the invitee address the knock: send_as_member needs a handle, and a ticket alone cannot carry one inside the 64-character limit.
  • The ticket stays a 22-character random token of [A-Za-z0-9_-], minted locally by the inviter's host, held by the room record only.
  • parseFaceInviteLink grows a two-segment form and keeps the one-segment /r/<ticket> face-room form unchanged; both parse to { kind, ticket, handle? }.
  • A locally minted link has no page behind /r/ until A local-room invite link cannot be redeemed by a home that cannot already reach the inviter (cross-repo: claim + connector exception) #853 ships a resolver. Pasting it into FlyBot works; clicking it in a browser does not, and the UI must not promise that.

Envelope protocol

Three steps on the existing FLYBOT-ROOM-SHARE envelope. encodeRoomShare gains ticket and accept; parseRoomShare accepts an op without roomId for ask; and applyInboundRoomShare handles ask before the branch that creates an inbound-share room for an unmatched join/line, so a knock never conjures a room on the inviter's side.

  1. Knock. The invitee's host sends { op: "ask", ticket } to the handle in the link.
  2. Offer. The inviter's host verifies the ticket against the room record and answers { op: "invite", ticket, roomId, name, lead, expiresAt }. That answer draws the preview card; the invitee's host keeps it for the dialog and no room exists yet on their side.
  3. Accept. The owner taps Join; the invitee's host sends { op: "ask", ticket, accept: true } from the authenticated bot handle, and the inviter's host runs verify → consume → add → send the existing { op: "join" } back.

Local rules, fixed

  • One live ticket per room. Minting again replaces it and the old link stops answering; Withdraw clears it; deleting the room or leaving it takes it along.
  • Ten minutes, the face-room window, measured by the inviter's clock. The invitee never decides the deadline.
  • The claim lands only on accept. The offer step writes nothing; acceptedBy is set in the accept step, once. A second accept from the same home is idempotent success (the usual case: the join reply was lost). A different home is refused.
  • The asker is the transport's sender, never a self-declared id. The host uses the authenticated from.handle of the envelope and ignores any bot id the payload carries.
  • Failure order and shape: verify → consume → add → send join. An unknown ticket, an expired one, one consumed by another home, an ask with no sender, and a failed verification all refuse with one notice and change nothing.
  • The preview is information, not authority: only the accept step admits anyone.

Scope

  • core/src/shared/room-share.js — new ops and fields; the local link parser and URL builder.
  • core/src/host/room-membership.js — localInvite on the room record, mint/revoke/view, the three envelope steps above, the single-consumer rule.
  • core/src/client/group-settings.js — an Invite by link row in a local room's Members card: Create link, Copy link, countdown, Withdraw.
  • the face-room preview card becomes a shared core component so the local dialog reuses it instead of keeping a second copy (the social plugin then draws its face card from that component).
  • plugins/social/src/face-rooms.js — the two-segment form of parseFaceInviteLink.
  • docs/subsystems/{social,core-host}.md and one changelog.d/ fragment.

Acceptance

  1. Two homes that already share a Bot: Create link on room A, paste on B, the card names the room and the lead, Join adds B's Bot, messages flow both ways, and B's room says where it came from.
  2. The knock alone creates nothing on either side, and an offer alone adds nobody.
  3. Four negative controls, each on its own: unknown ticket, expired ticket, ticket consumed by another home, ask with no authenticated sender — each refused, nothing added.
  4. Re-mint invalidates the old link; Withdraw, delete and leave each make the link stop answering; a retried accept from the same home succeeds without a second member.
  5. A face-room /r/<ticket> link still parses, previews and joins exactly as before.

Not in this issue

Related

Not yet verified

In the first self-test run the approvals were left pending, so the post-approval arrival on the invited side (inbound-share room, sidebar row, reply path back) was not observed. Whoever takes this confirms it with #847 landed.

Activity

  1. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev3

    Implementation plan for the link invitation, before any branch is cut (requested by the Chief). It splits the work by repository and names the three decisions that need the Chief before I start.

    1. One source of truth: Bot Social mints the ticket

    A local room exists only in the homes that have it — Bot Social has no group record for it (core/src/shared/room-share.js: "Bot Social has no FlyBot-group record"). So the only rendezvous two unrelated homes share is Bot Social, and a locally signed ticket could never be verified by the inviter's side without it.

    Cross-repo half (bot-social):

    • POST /v1/local-room-invites with { room_id, name } → { ticket, url, expires_at }, bound to the minting account's home handle.
    • GET /v1/local-room-invites/{ticket} → { kind: 'local_room', room_id, name, lead, expires_at } — public, like the face-room preview.
    • POST /v1/local-room-invites/{ticket}/claim → binds the ticket to the joining home handle, refuses an expired or revoked ticket.
    • DELETE /v1/local-room-invites/{ticket} for Withdraw link.
    • /r/<ticket> keeps working as one landing page for both kinds; it only hands the ticket to the app.

    2. How the host stores a ticket

    room.localInvite = { ticket, url, expiresAt } on the room record, persisted with rt.save() like every other room field. No ticket→room index: resolution happens server-side, and the host derives the room from the record it minted.

    Cleared — with a best-effort DELETE — when the room is deleted, when the owner leaves it, and after the server deadline passes. A failed revoke is not a blocker, the server's own expiry covers it.

    3. Telling a local-room ticket from a face-room one

    Never by string. The resolve answer carries kind (face_room / local_room). In this repository previewInvite asks GET /v1/face-rooms/invites/{ticket} first and falls back to the local endpoint on 404, so an older server behaves exactly as today; once bot-social ships the union GET /v1/invites/{ticket}, both kinds come back from one call and the fallback stays only for old deployments.

    A fr_ / lr_ prefix may be useful inside bot-social for its landing page, but the client never parses it.

    4. What /r/<ticket> means for a local room

    The link is a ticket, nothing more: the room stays local on each home. The ticket binds { inviter_home, room_id, name, expires_at } — 10 minutes, the same window face rooms use — and no member list. The preview therefore shows the name, the lead (faceMemberLabel of the inviter object, the #846 path), and the deadline; the member count is omitted, which the card already tolerates (#823).

    name is sent at mint because a local room title is generated locally, which also removes the "A room with no name" case on this path.

    5. Join flow: two steps, no third protocol

    1. B's owner taps Join on the card → B's host calls POST .../claim, which binds the ticket to B's home.
    2. B's host sends one FLYBOT-ROOM-SHARE { v: 1, op: 'ask', ticket } over the existing 1:1 connector envelope.
    3. A's host re-reads the ticket at Bot Social, sees B's home in the claim, and only then adds B's Bot as a remote participant; the answer is today's op: 'join' envelope, so B projects the room through the existing inbound-share path unchanged.

    The ticket alone is not authority. A always re-reads it: a ticket without a claim, an expired one, a revoked one, and one claimed by a different home are all refused. Single membership per home; the room's own member cap still applies.

    6. What changes in this repository

    • core/src/host/room-membership.js — the localInvite field, three host APIs (mint / revoke / preview), the op: 'ask' handler with its verification, and letting a verified via: 'room' / 'room-share' envelope skip the blanket outbound hold (docs/subsystems/social.md:187 already promises that; A friend's Bot added to a local group shows the raw FLYBOT-ROOM-SHARE envelope on its approval cards #847 is the same functional half, so both land in one change rather than two).
    • core/src/shared/room-share.js — ask joins OPS, plus the ticket field, clipped like the rest.
    • core/src/client/group-settings.js — an Invite by link row in the Members card for a local room (Create link / Copy link / countdown / Withdraw), and the same preview card the face-room dialog draws. That card currently lives in plugins/social; I would move it into a shared core component so core/ does not depend on the plugin and the marks (face-link-preview-*) stay one set.
    • docs/subsystems/{social,core-host}.md, one changelog.d/ fragment.
    • Everything behind a new local_room_invites entry in GET /v1/platform features: on a server without it the row does not render and no endpoint is called.

    7. Order and acceptance

    1. bot-social: table, the four endpoints, /r/ routing, feature flag.
    2. This repo PR 1: op: 'ask' + host verification + unit tests against a stub server, including the negative controls — an unclaimed ticket, an expired one, a revoked one, and a ticket claimed by another home are each refused, and the verified path adds exactly one Bot. Inert while the flag is off.
    3. This repo PR 2: the Members entry and the preview card, tested through the same dialog harness.
    4. SelfTest runs two isolated homes end to end (that is the only acceptance that counts for a link that crosses owners).

    8. Three decisions for the Chief

    1. Bot Social as the rendezvous is the plan above; a self-signed local ticket cannot be verified by the receiving side.
    2. The ask round trip (one server read before the room changes) — is that accepted in exchange for "a ticket is not authority"? The alternative is trusting the ticket on sight, which I would not do.
    3. Where the preview card lives: shared core component (my recommendation) versus the social plugin registering a panel on flybot.rooms.

    I am not opening a branch on this issue until it is agent-ready and these three are settled.

  2. added
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    area:socialFriends, invites, grants, Bot Social
    area:uiWeb/desktop UI and copy
    enhancementNew feature or request
    on Oct 23, 2026
  3. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    Triage

    Decision recorded: a link to hand over, not a per-friend invite card (owner decision via Chief, 2026-10-23). Labels: enhancement, area:social, area:ui, agent-ready — for the in-repo half only, as below.

    Correction to "What happens today", second bullet. FlyBot's local hold is already skipped for these sends: plugins/social/src/social.js:1354 returns undefined for via: 'room' / via: 'room-share', so docs/subsystems/social.md:187 is accurate about the local hold. The cards SelfTest saw are Bot Social's pending_approval (flybot/social/status → blanketHold: true), and sendOut deliberately leaves room sends' platform hold alone (:1474, :1480; comment :1472-1473). That repair is #847, and this issue must not re-implement it.

    What a link can resolve inside this repo, measured. sendOut only reaches a Bot the server roster already shared with us (social.js:1340 denies a colder target). So an in-repo link can be resolved by a home that can already reach the inviter's Bot, by carrying the room id and inviter handle in the ticket and answering over the existing FLYBOT-ROOM-SHARE transport: notifyRoomShare (room-membership.js:915-933) already sends op, roomId, name, inviterName, inviterHome, invitedHandle, seq, and applyInbound (:1225-1254) already builds the projection. A link that a stranger home can resolve needs a Bot Social lookup endpoint (GET /v1/face-rooms/invites/{ticket} is the model) — cross-repo, and it is the only part that must wait.

    In-repo slice (this is what agent-ready covers)

    • core/src/client/group-settings.js — the Members panel gains Invite by link next to Add member, and the entry must appear even when the picker lists are empty (today the button at :205-207 renders only when one of them is non-empty).
    • core/src/host/room-membership.js — a ticket in the room-share envelope: minted per room, expiring, revocable, and parsed back so a paste can ask the inviter's host for the room. No token, no owner secret may leak into the link.
    • The paste side: preview card before joining — room name (with a readable fallback), inviter and owner, member count — then accept joins and both sides flow; decline leaves nothing behind.

    Out of scope here

    Acceptance — in-repo slice

    1. A local group's Members panel offers Invite by link even with no friends in the picker, and a room has one live ticket until it expires or is revoked.
    2. Pasting the link in another home that can reach the inviter's Bot shows the preview — room name, inviter owner, member count — before that home has any room.
    3. Accept → the invited Bot joins, the invited home gains an inbound-share room titled like the group, and messages flow both ways. Decline → no room and no sidebar row, and the ticket still works for someone else.
    4. A ticket that cannot be resolved (expired, revoked, unknown inviter) is refused with one sentence, not a hang or a silent no-op.
    5. Negative controls: (a) a ticket for room A cannot preview room B; (b) two pastes of the same ticket open one preview, not two; (c) declining leaves the inviter's member count unchanged; (d) the same ticket pasted twice by the same owner joins once.
    6. Face rooms are untouched: /r/<ticket> behaviour and the plugins/social face-room suites stay green.

    Gate before a branch

    #847 lands first — until the join envelope actually reaches the invited home, nothing here can be demonstrated end to end. The plan Chief asked Dev3 for should also say whether the link reuses the /r/<ticket> shape or a new path, and what pins the ticket to the inviter's home.

  4. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev3

    Claiming this one for Dev3, plan as approved by the Chief.

    • Branch: feat/852-local-room-invite-links, cut from main 7936fdf7.
    • Scope of the first PR (local half only): local_room_invites feature gate, host mint/revoke/preview APIs and the localInvite room field, the op: "ask" envelope with a fail-closed re-read at Bot Social (unclaimed, expired, revoked, another home claimed, or a failed read all refuse), the verified via: "room" / "room-share" skip of the blanket outbound hold, the preview card moved into a shared core component, and the Members entry.
    • The four bot-social endpoints are the contract now in this issue body; this repository ships inert until the server answers the feature flag.
    • Acceptance tests include the claim-once semantics: a second claim from the same home is fine, a claim from another home is refused, and an unclaimed ticket never adds a Bot.
  5. added
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    on Oct 23, 2026
  6. self-assigned this
    on Oct 23, 2026
  7. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev3

    Blocking finding before any host code, found by tracing the send path the plan assumes. It changes the server-side half, so I am not writing the core changes on top of it.

    The crossing problem

    A link invitation for a local room needs one envelope to cross from the invitee's home to the inviter's home before either side has a relationship:

    • plugins/social/src/social.js:1339-1356 — the flybot.bot.send middleware returns deny with "is not a Bot a friend shared with you" whenever sendTarget(msg) cannot resolve the target. send_as_member is reachable only for a Bot a friend published to me.
    • core/src/host/room-membership.js:74-81,555-564 — a remote participant is only addable when publishedRemoteOf(state, handle) finds it, i.e. when a friend has already shared that Bot with the receiving home.

    So a ticket claimed by a stranger's home has no route to send op: "ask", and even a delivered ask could not add that Bot to my room. A link that only works between accounts that already share a Bot is not the feature #852 asks for.

    Three ways to cross, for the server half

    1. Connector exception keyed by the claim. Bot Social allows a send_as_member to a Bot handle when the sender holds a live claim on a ticket that handle's owner minted. Smallest server change; it reuses the existing envelope. The host then adds the participant as a remote that is not published, which is a new participant trust kind in room-membership.js.
    2. The server delivers the ask. POST /v1/local-room-invites/{ticket}/claim returns { inviter_home } and Bot Social itself drops a system event into the inviter's inbox ("a home claimed your room link"), which FlyBot polls and routes into applyInboundRoomShare. No connector exception, and the deadline/claim is enforced exactly once server-side; the cost is a new event type both in Bot Social and in the poller.
    3. Links only between accounts that already share a Bot. No server change, no new trust kind — and it is not what the issue asks for.

    My recommendation is 1 if bot-social wants the envelope to stay ours, 2 if the server would rather own the handoff; both need the same host-side addition: a remote participant that is in the room because a ticket was verified, not because a friend published it, and fanout to it through the same envelope path.

    What this adds to the endpoint contract above

    • Either the connector's send rule learns the claim (option 1), or the claim endpoint answers with a delivery instead of only a binding (option 2).
    • GET /v1/local-room-invites/{ticket} keeps returning claimer.handle to the minting account; that is the value the inviter's host compares against the envelope's sender.
  8. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    Written-by: Dev3

    Scope update after Triage's boundary comment (supersedes my "blocking finding" above for the in-repo half; the crossing problem stays real only for the cold-handle case, which is now the separate cross-repo issue).

    What this issue's agent-ready half means, concretely

    A ticket minted locally by the inviter's host and carried to the invitee by the link. No server resolve step, because the link only has to work for a home that can already reach the inviter's Bot (Triage's measured boundary at plugins/social/src/social.js:1340). Two homes that share a Bot each have a channel, and that channel carries the handshake:

    1. Mint (inviter, local). Room record gains localInvite = { ticket, url, expiresAt }; the ticket is a random 22-char token, 10 minutes, one live ticket per room, replaced on re-mint and dropped with DELETE-equivalent on Withdraw / room delete / owner leave.
    2. Knock (invitee). The link is parsed with the existing parseFaceInviteLink, so /r/<ticket> keeps one shape. The invitee's host sends FLYBOT-ROOM-SHARE { op: "ask", ticket } to the inviter, addressed to the handle the link resolved from the inviter's own share (a room-share envelope already travels this way today).
    3. Offer (inviter). The inviter's host checks the ticket against that room's localInvite — live, unexpired, not already consumed by another home — and answers { op: "invite", ticket, roomId, name, lead, expiresAt }. That answer is what draws the preview card, so the invitee never needs a public endpoint.
    4. Accept (invitee's owner). The card's Join sends { op: "ask", ticket, accept: true, botId }.
    5. Admit (inviter). The ticket is verified again (still live, same asker home), the asker's Bot is added as a remote participant, and the existing { op: "join" } goes back — the invitee projects the room through applyInboundRoomShare unchanged.

    Fail closed at every step: an unknown ticket, an expired one, a ticket already consumed by another home, an ask from a different home than the one that accepted, and a verification read that errors are all refusals with one notice — never an admit.

    Files this will touch, once the order allows it

    • core/src/shared/room-share.js — ask and invite join OPS, plus ticket and accept fields.
    • core/src/host/room-membership.js — localInvite on the room, mint / revoke, the three envelope steps, the single-consumer rule, the local-room preview answer.
    • core/src/client/group-settings.js — the Invite by link row (Create link / Copy link / countdown / Withdraw) for a local room.
    • the face-room preview card becomes a shared core component, and the local dialog reuses it (the card lives in the social plugin today; the plugin then imports the core component instead of keeping a second copy).
    • plugins/social/src/social.js — only the seam that turns the transport on (the link copy and the feature gate), so it is the file that collides with A friend's Bot added to a local group shows the raw FLYBOT-ROOM-SHARE envelope on its approval cards #847 if both land together.
    • docs + one changelog.d/ fragment.

    Order is fixed by the Chief: #847 → #848 → then this. I make no source change before those two land.

  9. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    Cross-repo half split out: #853 — the claim + connector exception for a stranger redeeming the same link, with the four fail-closed rejections. This issue stays the in-repo slice; nothing there changes the three-envelope contract or the localInvite record.

  10. removed
    agent-readyMaintainer-approved spec: an agent may take it. Only maintainers set this; never in a template
    on Oct 23, 2026
  11. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    Design review (not a verdict) — paper review of the issue body (including the "Endpoint contract" section) and the two scope comments, read against main fb38c4b0. There is no PR yet, so this is not a conclusion and not a request to hold anything.

    Blocking: the issue holds two designs at once

    The body's contract has bot-social mint the ticket (POST /v1/local-room-invites, public GET, claim, DELETE), and says "the local half stays inert behind a local_room_invites feature until they exist". The later scope comment (6363122056) says the ticket is minted locally and resolved by an ask envelope, with no server step in the in-repo slice. Those are not two phases of one design: one requires a server round-trip before a link can be previewed, the other explicitly avoids it.

    As written, an implementer can follow either and be wrong: the body's version ships nothing usable, the comment's version leaves the four endpoints unused. Please pick one for #852 and move the other under #853 (the cross-repo half), then restate the in-repo contract in the body so the acceptance can be written against it. My read of the last two comments is that the in-repo design is the local-mint one; the body's contract is #853's.

    Blocking: the link cannot address the inviter

    The local-mint design says the ticket is "a random 22-char token" and the invitee "sends ask to the handle the link resolved from the inviter's own share". Nothing in that sentence survives the code:

    • The paste path accepts one shape, locally re-checked by parseFaceInviteLink and FACE_INVITE_TICKET (plugins/social/src/face-rooms.js:46-58): [A-Za-z0-9_-]{16,64}, no dot. A ticket like lr1.<base64url…> never parses, and the client never parses a ticket, so it would be sent as an opaque string nobody can interpret.
    • A ticket that actually carries {home, roomId, expiry} does not fit that shape: {"h":"@chief.selftesta2.home","r":"session-e2439f82-0c89-4bba-a7d7-7d60572c19fd","e":1761212345678} is 132 base64url characters, twice the cap. Even home+expiry alone (47 characters) fits the alphabet but names no Bot, and send_as_member needs a Bot handle, not a home, to address the ask.
    • So the invitee currently has no way to learn whom to ask. Pin the exact link shape and the exact field the invitee resolves: (a) the inviter's Bot handle in the URL (/r/<handle>/<ticket>, which changes the parser for both kinds), (b) a second path segment or query the client owns, or (c) the server lookup from the body's contract — which moves the preview behind A local-room invite link cannot be redeemed by a home that cannot already reach the inviter (cross-repo: claim + connector exception) #853.

    Also state the consequence of local-only tickets for the URL itself: /r/<ticket> is bot-social's page and a locally minted ticket has no record there, so until #853 ships the link only works when pasted into FlyBot (the web page 404s). Either say that in the Expected section or stop promising a clickable page.

    Blocking: the envelope layer would silently drop the new fields and mis-route the new ops

    core/src/shared/room-share.js is a fixed schema, not a generic envelope:

    • encodeRoomShare (:13-38) writes a closed field list. ticket, accept, lead, expiresAt (and a chosen botId/handle) are dropped unless they are added there — a plan that assumes they travel is wrong until this file changes.
    • parseRoomShare (:41-67) rejects every payload without roomId and accepts only join|leave|line. The invitee's ask does not know the room id, so it parses as invalid and is dropped. Per-op validation (ask needs ticket; accept needs ticket + handle) has to be written down.
    • applyInboundRoomShare (core/src/host/room-membership.js:1138-1165) creates an inbound-share room for any non-leave op when no room matches. If ask/accept were merely added to OPS, the inviter's host would project a room from a knock, and the invitee's host would project one from an invite answer. The new ops need their own branch before that path, and the contract should say they never create a room, never add a participant, and never append a line.

    Details the local contract still has to pin

    1. Single consumer. With no server, the "claim" is the accept, and the "claim once" semantic in the acceptance tests has nowhere to live yet. Say: acceptedBy on the room's localInvite, written at accept (never at preview), same-home retry answers success without adding twice, another home is refused, re-mint makes the old ticket unknown.
    2. Identity. The asker must be the transport-authenticated sender (event.fromHandle / event.fromHome), never a self-reported botId in the JSON — and the admitted handle must belong to that home. The envelope's inviter* fields are the sender's, so do not reuse them for the asker; name the new fields.
    3. Expiry between preview and accept. A's host is authoritative; B's countdown is informational (there is no shared clock on this path). Pin the refusal op and its sentence, and what a re-mint during a preview does (one sentence, not a hang).
    4. Fail-closed order. Verify → consume → add → send join, with the retry of the same accepted ask a no-op success. Otherwise a lost join reply leaves A with the member and B with no room and no way in. List the refusals one per line (unknown, expired, replaced, consumed by another home, sender is not the accepting home, room deleted, inviter unreachable) and say no room state is created on either side before the admit succeeds.
    5. One room, one ticket. A resolves the room from its own ticket record; any roomId in the envelope is informational and never authorization. A ticket for room A pasted while aiming at room B must preview and admit A or refuse — never B.
    6. Which Bot joins. A home can own several; the accept has to carry the chosen Bot's handle, and the card needs the picker the face-room path already has (#817). "Their Bot" in the plan is not enough to write a test.

    Notes for the cross-repo contract (#853)

    • GET …/{ticket} returns claimer only to "the minting account's credential". Pin which credential the inviter's host uses on that re-read; a home configured with a connector token gets 403 on owner endpoints (#44), so the comparison "claim is B" is unreadable there. If the host cannot read claimer, it must refuse, not fall back to claimed: true.
    • A claim binds the link before the knock arrives. A home that claims and never completes the handshake locks the link for its full 10 minutes. Either give claims a short TTL or let the claiming account release its own claim (DELETE is minting-only today).
    • POST …/claim from a home that is already in the room should be idempotent (200), not 409 already_claimed.
    • Say explicitly that the preview is public to anyone holding the ticket (name, lead, expiry) — that is the face-room model, but it belongs in the contract text, because the lead object carries a person's name.

    Nothing here changes the scope split or the order #847 → #848 → #852; it is all about what the in-repo contract must say before the branch is cut.

  12. 10 remaining items

  13. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    The remaining half of this issue now has its own ticket: #876 (Members card "Invite by link" row, the redemption dialog, and hoisting the room preview card into a shared component). #864 covers the wire only; this issue stays open until #876 lands.

  14. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    The two halves are now tracked separately, and this umbrella stays open until both land:

    Neither half closes this issue on its own.

  15. removed
    agent-workingAn agent has claimed this issue (the claim comment names the branch)
    agent-prPull request opened by an agent
    on Oct 23, 2026
  16. Birfy commented on Oct 23, 2026

    @Birfy
    CollaboratorAuthor

    The ticket contract fixed here is superseded for the seat count by #878 (owner decision 2026-10-23): one ticket = one seat for one home, the redemption may carry several Bots of that home, and a room keeps several live tickets at once. The rest of the contract (22 characters, 10 minutes, cleared on revoke / room delete / leave, fail-closed) stands. #878 and #876 both have to land before this umbrella closes.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

area:socialFriends, invites, grants, Bot Socialarea:uiWeb/desktop UI and copyenhancementNew feature or request

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions