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.
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.
We sent a sign-in link to sam@northwind.co. Open it on this phone to join Weekly Product Sync.
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=+ areturnToback 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.goloadAndRegisterParticipant). The only hard block today is the strictAuthMiddlewareWithJWKSon the join group, which is now the intended gate, not a bug. - No display-name field — the
usersrow supplies name + avatar, solookupParticipantDisplaysresolves 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 padsenv(safe-area-inset-bottom).
2. Consent = joining
/join apps/web/app/(public)/.../join/V1 collapses the decision: the act of joining IS the consent. No Record / Transcript-only / Off cards — in V1 you're always recorded and transcribed, so the choice is binary: tap Join meeting (and consent), or don't, and you don't get access. One screen, one Copper CTA. The only nuance offered is voice recognition, which is default-on but needs a voiceprint recorded first. If the guest has already consented to this team, this screen is skipped entirely — they drop straight into the room (right).
2 · Join = consent
No cards to weigh. Recognize-my-voice is pre-checked (a convenience, not a recording grant — the recording happens regardless). "Don't join" exits without access.
- V1 simplification (intentional divergence from the consent proposal §2): the granular Record / Transcript-only / Off chooser is deferred post-V1. V1 has exactly one recording posture — recorded + transcribed — so a chooser with one real option is theater. The join button carries the consent; one tap = one
POST /api/v1/consent/events(grantaudio-retention+transcript, scopestandingon the recording's team) written at the tap. - Skip-when-consented: on entry, check standing consent for the team (
GET /api/v1/users/me/consentfiltered to the recording's team). If present → no consent screen, go straight to/join/context. No returning-guest interstitial. - Recognize-my-voice default-on, but gated on a voiceprint: the checkbox grants
voiceprint-idintent; recognition only happens once a voiceprint exists (deferred 60-sec emailed enrollment). It is NOT a recording toggle — recording is unconditional in V1, so pre-checking it can't dark-pattern someone into being recorded (that already happens by joining). Open question for the privacy review: default-on biometric intent vs BIPA — confirm with legal that the voiceprint isn't created until explicit enrollment, so the checkbox is "willing to be recognized later", not "enroll now". - "Don't join" = no access: declining doesn't drop them into a degraded room; in V1 there's no recorded-but-not-you state, so the only states are joined-and-recorded or not-in.
3. Live room — guest in the roster
/join apps/web/app/(public)/.../join/ · packages/cast-webThe 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).
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.
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/contextcallsUpsertRecordingParticipant(usr_, is_host=false)and auto-creates a speaker row. The guest shows inListRecordingParticipantslike anyone else. - Guest badge = "not a member of the recording's team". The roster view needs a per-participant
isMemberflag (jointeam_memberson the recording'scontext_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 rawbg-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.
4 · Guest access (no toggles)
Recording state is stated as a fact, not offered as a switch. The single action is leaving the team.
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.
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_membersguest 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/consentThe 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.
/j/{code} mints a fresh join token → 302 to /join?t=…?t= + returnTo preserved → back to /join, signed in (screens 1, 1a)UpsertRecordingParticipant(usr_, is_host=false) → guest in roster + recorded strip (screen 3)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 viaPOST /api/v1/consent/events— allow a non-member self-subject; (b) add the guest to the team as a guestteam_membersrow so revoke = remove that row (the only opt-out); (c) surface a per-participantisMember/isGuestflag 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?").