Guest join via QR (account required) — v1

2026-06-15 amends 2026-06-12 consent §2
Mode
⚖️
Decisions (locked): (1) a guest MUST be logged in — no anonymous participation; the account buys auditability (a named roster of everyone who attended, even silent attendees) and a real usr_ identity. (2) V1 consent is binary: joining IS the consent — you're recorded + transcribed, or you don't get access. No Record / Transcript-only / Off chooser, no mid-meeting toggles; the only opt-out is revoking guest access (forward-only). This amends the team-consent-management proposal (which had an anonymous gst_ guest and a three-way modality chooser) — the granular model is deferred until mid-meeting enforcement exists.

1. Account gate

/join?t=<qr-token> apps/web/app/(public)/.../recordings/[recId]/join/

Scanning the QR lands a no-session phone here. Before the room, the guest gets an account — magic-link is the default (no password to invent on a phone). The QR token (?t=) is preserved across the auth round-trip so they resume the exact meeting. No display-name field — the account carries the name. One Copper Continue.

9:41𝄃𝄃𝄂 ᯤ ▮
Join this meeting
AD Weekly Product Sync Acme Design Team LIVE NOW
you@company.com
or
🔎 Guests sign in so the meeting record shows who attended — including people who never spoke.

1 · No session — get an account

Email magic-link plus the same social providers configured app-wide (GitHub + Google today). The ?t= token + return URL ride through Better Auth and bring them back here signed in.

9:41𝄃𝄃𝄂 ᯤ ▮
Check your email

We sent a sign-in link to sam@northwind.co. Open it on this phone to join Weekly Product Sync.

📩 Link sent Expires in 10 minutes · your spot is held
The meeting keeps going — you'll drop straight into the room when you tap the link.

1a · Magic-link sent

Returning guests with a session skip screens 1 + 1a entirely — straight to consent (or the room, if already consented).

  • Owner: apps/web/app/(public)/team/[team_id]/media/recordings/[recId]/join/page.tsx. On no session, redirect to Better Auth carrying ?t= + a returnTo back to this exact join URL. The QR redirect (/j/{code}jointoken.IssueForQR) is unchanged.
  • Why account-required is cheap here: the backend already admits a logged-in non-member through the token path — the team-membership check is skipped when a valid join token is present (join_handler.go loadAndRegisterParticipant). The only hard block today is the strict AuthMiddlewareWithJWKS on the join group, which is now the intended gate, not a bug.
  • No display-name field — the users row supplies name + avatar, so lookupParticipantDisplays resolves the guest like any member (the anonymous flow needed a name because it had no user row).
  • Team avatar is the card's anchor tile (the "AD" tile), NOT a separate top app-bar. On a consent gate the trust-load-bearing fact is which org is recording me — so Acme's identity gets real pixel weight via the tile the F-scan already starts on, for zero added height. Product: the team's uploaded logo, with colored initials as the fallback (same pattern as the roster .halo .av); never a generic glyph. One identity anchor only — don't also add a title-bar avatar.
  • Perceptual trims (cognitive-load review): dropped the redundant "Sign in or create an account" label (the title + placeholder already say it) and de-boxed the "why sign in" explainer (a second bordered block flanking the CTA diluted the one Copper action). The screen's single job is account → room; everything else is annotation.
  • Mobile baseline: field + CTA ≥48px, min-h-dvh, home-indicator row pads env(safe-area-inset-bottom).

3. Live room — guest in the roster

/join apps/web/app/(public)/.../join/ · packages/cast-web

The same room members see. The guest now appears in the roster with a subtle Guest marker (so the owner can tell members from visitors at a glance). A non-dismissible consent strip sits at the bottom edge — status, not a modal — and is the always-available door to manage. No Copper on the room surface (cast theming; the room never sells anything).

9:42𝄃𝄃𝄂 ᯤ ▮
REC Weekly Product Sync — Acme
RSRyan
AMAjit
SMGuest
+2more
Ryan · host…so the Q3 roadmap lands the capture work first, then —
Ajitagreed, as long as the migration ships behind the flag.
✋ Raise hand 💬 😀
Recording You're recorded & transcribed

3 · In the room (guest = Sam)

Your halo carries a sage ring + “Guest”. The consent strip is the recording-variant PrivacyStrip — never dismissed, collapsed, or hidden.

9:42𝄃𝄃𝄂 ᯤ ▮
REC Weekly Product Sync — Acme
RSRyan
AMAjit
SMGuest
+2more
Ajit…and playback substitutes a computer voice for anyone on transcript-only.
Sam · guestquick question on the rollout window —
✋ Hand raised 💬😀
Recording You're recorded & transcribed

3a · Guest speaking / hand raised

Same recording strip — in V1 everyone in the room is recorded & transcribed; the strip states the fact, it isn't per-person choice.

  • Participant row already works: /join/context calls UpsertRecordingParticipant(usr_, is_host=false) and auto-creates a speaker row. The guest shows in ListRecordingParticipants like anyone else.
  • Guest badge = "not a member of the recording's team". The roster view needs a per-participant isMember flag (join team_members on the recording's context_id); render the badge on the owner-side roster too (§5) for auditability.
  • Consent strip is the cast-themed sibling of the recording-variant PrivacyStrip (packages/ui/src/privacy-strip.tsx) — inherits the immutability invariants (security/rules/privacy-strip-immutable.yml): not dismissible / collapsible / hover-revealed / flag-gated. Steady dot, no 60-minute pulse.
  • No Copper anywhere on the room surface — cast theming (bg-cast-*/text-cast-*), not raw bg-zinc-*.

4. Your guest access — the only opt-out

/join › access apps/web/app/(public)/.../join/

V1 has no mid-meeting toggles. You can't turn off recording or transcription for a meeting you're in — while you're a guest of Acme, you're recorded and transcribed, full stop. So this screen drops the per-modality dropdowns and states the fact plainly. The one opt-out is to revoke your guest access to Acme: you leave the team's meetings and won't be recorded in future ones. Forward-only — past recordings are unchanged. No Copper — leaving is a right, not a sale.

9:43𝄃𝄃𝄂 ᯤ ▮
‹ Back to meeting
Your guest access
Acme Design Team Guest
🔴 Recorded & transcribedOn for every Acme meeting you join — can't be turned off in V1
🎧 Your voice appears in 1 recording in Acme.
The only way to opt out
Revoke your guest access to Acme. You'll leave Acme's meetings and won't be recorded in future ones. Applies from now on. Past recordings keep what you allowed at the time.
Applies within 60 seconds · forward only.

4 · Guest access (no toggles)

Recording state is stated as a fact, not offered as a switch. The single action is leaving the team.

9:43𝄃𝄃𝄂 ᯤ ▮
‹ Back to meeting
Revoke guest access to Acme?

You'll be removed as a guest of Acme. You won't appear in — or be recorded by — future Acme meetings. You can be re-invited anytime.

Applies from now on. Past recordings keep what you allowed at the time.
🎙 Your voiceprint is yours, not Acme's — revoking only removes Acme's permission to use it. It isn't deleted; to delete the voiceprint itself, go to your voice settings.
Equal-weight buttons — leaving is never harder than joining.

4a · Revoke access confirm (equal weight, no Copper)

Verbatim non-retroactivity string, identical to settings + the receipt email.

  • V1 reality (the simplification): there is no mid-meeting kill-switch for capture — the recording pipeline records the whole room, so per-person "turn off audio now" doesn't exist yet. Don't render a control the backend can't honor. The honest model: membership-as-guest is the consent; removing it is the opt-out.
  • Revoke = remove the guest from the team (revoke the team_members guest row), which also revokes standing consent. Forward-only: the EffectiveStateAt engine guarantees past recordings keep what was allowed at capture time — no retroactive scrub.
  • No per-modality dropdowns — the granular Audio / Transcript / Recognize matrix from the consent proposal §3 is deferred until mid-meeting enforcement exists. Showing toggles that can't act is consent theater (the exact anti-pattern the consent guardrails ban).
  • Evidence count from CountRecordingAppearancesByTeam. Voiceprints are user-owned, not team-owned, and are never auto-deleted: "recognize my voice" grants a team permission to USE the user's voiceprint; revoking guest access removes that permission only. The voiceprint itself persists until the user manually deletes it in their voice settings — revoke must not claim or trigger deletion. 60s SLA honest — archiver re-poll; persistence gates at finalize.
  • Equal-weight buttons, no Copper, no danger-red default (revoke styling appears only on hover) — leaving must not be friction-shaped.

5. ★ Flow, owner roster & what actually changes

codification join_handler.go · router.go · packages/consent

The path from QR to room, the owner-side payoff (a named roster including guests), and the honest scope of the build — most of which already exists.

Flow
📷 Scan QR/j/{code} mints a fresh join token → 302 to /join?t=…
🔑 No session? → Better Auth (magic-link / Google), ?t= + returnTo preserved → back to /join, signed in (screens 1, 1a)
📝 No standing consent for this team? → the consent=join screen (screen 2). Already consented → no screen, straight to room
👥 Join (= consent)UpsertRecordingParticipant(usr_, is_host=false) → guest in roster + recorded strip (screen 3)
🚪 Opt out from the strip → not a toggle: revoke guest access to the team → forward-only, ≤60s (screen 4)
Owner roster — guests are visible (the payoff)
RS Ryan host Recorded
AM Ajit Recorded
SM Sam Guest Recorded

Silent attendees count too: a guest who never spoke is still a participant row, so the owner sees the full attendance — the core reason accounts are required.

  • Already works (no change): backend admission of a logged-in non-member via the QR token (team check skipped when token present); participant upsert + speaker row; the consent ledger / EffectiveState engine.
  • Frontend (most of the build): account-gate detour preserving ?t= (1, 1a); the consent=join screen with skip-when-already-consented (2); Guest badge in roster + recorded strip (3); guest-access view whose only action is revoke (4).
  • Backend (small): (a) on join, write the standing consent grant (audio-retention + transcript) for a self-subject scoped to the recording's team via POST /api/v1/consent/events — allow a non-member self-subject; (b) add the guest to the team as a guest team_members row so revoke = remove that row (the only opt-out); (c) surface a per-participant isMember/isGuest flag for the badge.
  • Flag: FEATURE_GUEST_JOIN — env kill-switch + PostHog per-user (Sacred-tier: live recording + consent), dev-default-on.
  • Verify before product code: the QR→account→consent→room handoff in a live mock — time-to-room for a brand-new account, and 30-second recall ("will your voice be in the recording?").