Skip to main content

Visitor Access — Implementation Plan

A person receives a link, opens it, types a name, asks to enter, and someone already in the office lets them in. They never register, never receive an email, and never exist in WorkOS.

Status: implementation plan, frozen at 2026-08-19 — the state in which the decision was taken. The dated revisions inside it ("reversed 2026-08-17", "settled 2026-08-07") are part of the record and are kept deliberately. Where this plan and the shipped code disagree, the code wins.

ADR-0005 is the decision this plan produced, condensed: read it first for what was decided and why, and this page for how it is built.


1. The idea in one paragraph

The link is a doorbell, not a key. Holding it lets you ask to enter; it never admits you by itself. That single property is what makes link lifetime a product choice instead of a security risk: a link can last ten minutes or forever, be forwarded, be posted in a shared document — and the worst a stranger can do is ring a bell that a member ignores.

Approval is per-invite, and the one lethal combination is forbidden in the schema (2026-08-07, after a brief reversal). requires_approval is a boolean the creating member chooses. A link that skips it trades the doorbell property for speed of entry — which is a legitimate trade when a member wants a client to walk straight in — so the guard is not "always approve" but the table constraint that forbids requires_approval = FALSE AND expires_at IS NULL: a link that never expires and needs no approval is a key, and §6 refuses to store one.

What auto-approval actually costs, stated plainly. The paragraph above tolerates forwarding because the worst outcome is an ignored bell. That tolerance is exactly what a no-approval link gives up: forwarded, it admits whoever holds it, immediately. So for those invites expires_at and max_uses stop being conveniences and become the only controls there are — the product surface should push them short and capped, and a review that sees a long-lived uncapped no-approval link should treat it as a finding rather than a preference.

Attribution survives either way, which is why this is safe to offer. With approval, the grant is the admission and decided_by_user_id names who made it (§6). Without approval, the grant is the invite itself and created_by_user_id names who made that. The audit question — who let this outsider in — has an answer with a human's name on it in both shapes; it simply sits one level up when the link decides. NIST AU-3's "identity of any individuals … associated with the event" is satisfied by either, because in both the granting act was performed by a member.


2. Vocabulary — this feature is visitor, not guest

Forced by a constraint, not chosen. guest is already a shipped wire value: OrganizationRole carries it in api/openapi.yaml:2356 and contract/src/zod.gen.ts:328, pinned by contract/src/smoke.ts, published as "OrganizationRole minus guest, which arises from invite flows and cannot be assigned." It means a registered WorkOS user with a reduced org role — a different concept, frozen onto the word.

So the unregistered link user is a visitor. Zero occurrences in the repo today, and it is Miro's word for exactly this: Miro ships Visitors (no account, link access) and Guests (registered account, added to specific boards) as distinct tiers.

The industry does not agree on "guest" — Teams, Slack and Miro use it for the registered external; Zoom and Gather use it for the unregistered one. Deciding rather than assuming is the point.

  • Visitor invite — the shareable link and its server-side record.

  • Visitor join request — one person asking to enter, created when they submit their name.

  • Visitor — a participant with no account, present in a LiveKit room only.

  • Arrival modeoffice or conference, a property of the invite.

  • Visitor session — one browser's pseudonymous identity, spanning offices and organizations (§6b).

  • Claim — the explicit act of attaching a WorkOS identity to a visitor session (§6b).

Operation names repeat verbatim across handler, service, role interface, file name and DTOs: CreateVisitorInvite, RevokeVisitorInvite, ListVisitorInvites, ListVisitorJoinRequests, ApproveVisitorJoinRequest, DenyVisitorJoinRequest, KickVisitor, GetVisitorInvitePreview, CreateVisitorJoinRequest, GetVisitorSession, CreateVisitorOfficeToken, ClaimVisitorSession, ListVisitorAccesses.

GetVisitorJoinRequest is gone, absorbed by GetVisitorSession (§7). A ListVisitors was considered and rejected: with a status filter on ListVisitorJoinRequests it would be a second operation over one table split by status, and the name lies — a denied person never became a visitor.

Three permissions, not one (revised 2026-08-17)AdmitVisitor, ManageVisitorInvites, KickVisitor. An earlier draft put the whole surface behind AdmitVisitor alone; that was the anomaly, because the member tier already separates exactly these three axes in internal/permissions/models.go — granting (InviteUser, InviteGuest), invite management (ListInvitations, RevokeInvitation, GetInviteLink, ResetInviteLink) and removal (KickUser, BanUser). §7 maps the operations; the reasoning for where the two list operations land is there too.

None of them may be guest-named, and that is the hard constraint rather than a style preference: permissions.InviteGuest already exists and means the registered tier. Two guest-named permissions for two different principals in one catalogue read by one RBAC.Can is how a distracted review becomes a security bug. KickVisitor reads unambiguously beside the existing KickUser for the same reason.


3. Decisions already settled

DecisionChoice
Visitor identityNo WorkOS user, no private.users row, no RBAC role, no principal in the request pipeline
Where the visitor's secret livesInvite secret in the URL, scrubbed after first read, then sent in the request body — never a cookie (§8); session secret in an HttpOnly cookie scoped by Path — never sessionStorage. There is no per-request secret (§6)
Is the secret a JWT?No — opaque, 256-bit, SHA-256 at rest (§8)
Waiting visitorOutside LiveKit entirely — the pre-join screen, no hidden lobby participant, no participant-minute burned
Media tokenMinted only after approval, on its own endpoint, never on the poll
Source of truthPostgres, never LiveKit room metadata
Visitor learns of approvalPushed on visitor:{sessionPublicId}; GetVisitorSession is baseline, fallback and token re-issue (§9). An auto-approved knock needs none of it — it answers approved inline
Visitor on CentrifugoYes — one channel, their own. Server-side subscription in the token's channels claim, no meta.org, TTL covering the wait. Never a second channel (§9)
Members learn of a requestCentrifugo office: namespace, published in the same transaction
Members get the current listREST fetch, triggered by subscribing
Approver must be in the officeYes, enforced server-side (§10)
Who removes a visitorA human in the inviting org, never a timer, and only through KickVisitor — one person at a time. RevokeVisitorInvite closes the link and removes nobody. A connected visitor stays indefinitely; the org bears the connected time (§12)
Arrival modeOffice or conference, chosen at invite time, server-set participant attribute
Trust labelServer-stamped; CanUpdateOwnMetadata denied so it cannot be rewritten
Email / push notificationOut of scope
OfficeMetadata.GuestStateNot used; left in place, frozen
PermissionsThree, not oneAdmitVisitor (admit/deny/queue), ManageVisitorInvites (create/revoke/inventory), KickVisitor. Defaults: management admin+moderator; admit and kick also member; all three false for RoleGuest (§2, §7)
Entry and removalOne release, never two. A trunk where visitors can enter and cannot be removed is a broken release (§17)
Deleting an officeStays possible, always. Hard delete, no deleted_at anywhere, no ON DELETE RESTRICT anywhere (§6, §15d)
Erasing a member or a visitorPossible in potency, always. Every attribution FK is SET NULL; anonymise-in-place is the preferred operational path, not a schema-enforced one (§6)
Retention periodsNot decided. The schema is shaped so a sweep is cheap to add later — tenant key, retention anchor, and identifiers segregated from aggregates (§15d)

4. The flow

A. A member creates the link (CreateVisitorInvite, cookie auth, ManageVisitorInvites). The server generates a 256-bit secret, stores only its SHA-256, and returns the link once. The member chooses arrival mode, whether approval is required, when the link expires (or never), an optional cap on uses, and an optional label to tell their own links apart in the list (§6a). The schema refuses the one combination §1 forbids: no approval and no expiry.

The link is https://app.qubital.space/join/<inviteSecret>. The office id is not in the URL — the secret resolves to the invite row, which names the office. Putting it in the URL would disclose which office a stranger was invited to before admission, and would be editable.

B. The visitor opens the link. Pre-join screen: camera and microphone preview, name field. The frontend reads the secret, history.replaceState()s it out of the address bar and history, and the page carries Referrer-Policy: no-referrer — the two leak vectors W3C TAG's capability-URL guidance names. GetVisitorInvitePreview returns the organization name, the office name, the arrival mode and whether approval is required — enough for the screen to promise the right thing (§7), and nothing beyond it.

C. The visitor knocks (CreateVisitorJoinRequest). One transaction does everything: the server validates the name and the invite, creates the row, assigns the LiveKit identity visitor-<uuid> (stable for the lifecycle of that request), and returns requestId — while setting the session cookie, minting the session if this browser has none (§6b, §8). No media token yet. What happens next forks on the invite:

requires_approval = TRUErequires_approval = FALSE
Row is bornpendingapproved, auto_approved = true, approved_until stamped
decided_by_user_idNULL until a member decidesstays NULL — nobody decided (§6)
use_countuntouched; consumed at §4Fincremented here, conditionally against max_uses
Office publish on the outboxyesno — there is nothing to answer
Response carriesrealtimeToken + connectURLneither (§9)
Next stepwait (D–F)straight to CreateVisitorOfficeToken

The rule for use_count reads the same in both columns: a use is consumed when someone gets in. That is the approval in §4F and the knock here — one meaning, two sites, never both for the same admission.

An exhausted invite fails the knock with the uniform visitor-invite-not-usable (§14), not the member-facing visitor-invite-exhausted: the visitor's pre-join screen must give nothing to differential-time against, and "this link is full" is exactly the sort of thing it must not say.

The realtime token — when there is one — is minted here and not earlier, because the channel it authorises is visitor:{sessionPublicId} and the session does not exist until the first knock creates it (§6b); on later knocks the session is already there and the token is simply reissued. Nothing is created when the link is merely opened: step B stays a pure read, so a bot hitting the preview creates no rows and the abuse caps stay where they belong, on the knock.

D. Members are notified. The office publish from C lands on office:{orgId}:{officeId}, committed with the row rather than after it. If nobody eligible is present, nothing is delivered and the request simply stays pending — "empty office" is not a special case, just "nobody has answered yet".

E. The visitor listens. The client opens the websocket with the token from C and subscribes to its own channel — a server-side subscription carried in the token's channels claim, so the client never asks for a channel and the subscribe proxy is never involved.

Immediately after the socket is established it calls GetVisitorSession once as a baseline, then relies on the push. That single call closes the race between the knock returning and the socket coming up: a member approving in that window would otherwise publish into a channel nobody is listening on yet, and the namespace carries no recovery. It is the same subscribe-then-re-baseline discipline the member side uses on the office channel.

The same endpoint is the fallback when the websocket cannot be established at all — corporate proxies, hostile networks — in which case the client polls it at 2s for the first 30s then 5s, ±10% jitter, server-driven, stopping after ~5 minutes with a control that resumes. The request itself stays admissible ~15 minutes either way.

F. A member approves (ApproveVisitorJoinRequest, cookie auth, AdmitVisitor, and present in the office). One transaction: the invite's use_count is conditionally incremented against max_uses first (§6 — this is where a use is consumed, not at the knock), then the status flips to approved with an approved_until deadline and decided_by_user_id is stamped with the approving member. Two publishes commit with it: one on the visitor's own channel carrying the outcome, one on the office channel so the other members' UIs drop the entry.

If the invite is already exhausted the whole transaction fails with visitor-invite-exhausted (§14) and nothing is decided. Two members admitting two different knocks on a max_uses = 1 invite therefore produce exactly one admission, and the loser is told why after clicking — worth copy that says so, since they may already have told the visitor they were coming in.

G. The visitor is pushed the outcome and calls CreateVisitorOfficeToken once. That endpoint checks the live approval for this office, the seat count and the mint cap, then mints: RoomJoin, CanPublish, CanSubscribe, CanPublishData, publishable sources enumerated explicitly, CanUpdateOwnMetadata false, identity visitor-<uuid>, display name as typed, arrival mode and trust label as server-set attributes, TTL models.TokenDuration (10 minutes). Returned in the body — never a cookie, never a URL.

H. The visitor joins. Everything after this is LiveKit. They may call the token endpoint again on any cold re-entry — page reload, device switch, network drop — for the whole approved_until window, without re-knocking. LiveKit refreshes tokens for already-connected clients itself, so the 10-minute TTL governs joining, not session length.

I. They leave when they leave. Nothing disconnects them on a timer. The one thing that removes them is KickVisitor, which takes effect immediately and also blocks re-entry (§12). Revoking the invite does not reach them — it closes the link behind them and removes nobody. approved_until only stops them coming back without asking again.


5. State machine

┌── approve ──> approved ──(approved_until passes, LAZY)──> expired
pending ───────────┤ │
│ └── deny ─────> denied└── kick ──────────────────────────> expired
├──(expires_at passes, LAZY)──> expired
└──(invite revoked, LAZY)─────> expired

Revocation reaches pending and stops there (2026-08-17). Revoking the invite closes the gate, so a knock still waiting on it can never be admitted and reads as expired. A row already approved is past the gate — its admission was consumed at approval and its re-entry is checked against the approval, not the invite — so revocation does not touch it. Removing an admitted visitor is KickVisitor, per person (§12).

A row born approved is the auto-approval path (§4C): the knock itself writes the admission on an invite with requires_approval = FALSE, auto_approved = true, no decider. It enters the diagram at approved and behaves identically from there — the same approved_until, the same lazy expiry, the same kick and revoke.

Both time transitions are computed at read time, never written by a job. The writers are approve, deny, kick, revoke — all human actions — and the knock, but only on an auto-approving invite, which is a human action too, taken earlier, by whoever created the link.

  • pending lives ~15 minutes — long enough that a member walking into the office still finds the knock. Teams removes an unadmitted lobby participant after 30 minutes; Zoom documents no timeout at all. Our earlier 5 minutes was too aggressive, and it broke the very case §4D cares about.

  • The client polls for only ~5 of those minutes. Two different numbers on purpose: bounding the poll loop and bounding the request are different concerns.

  • approved stays redeemable until approved_until. This bounds re-entry, not the live session: a connected visitor is never disconnected by it.

    The value is deliberately not fixed here, and there is no solid precedent to copy. No product documents a durational admission window with any confidence:

    • Gather, ~12 hours — a guest revisiting within 12 hours of first clicking re-enters without a new invite, then respawns in the lobby. Single-sourced, medium confidence, and not re-verified: it comes from prose in one 2.0 help article, not a settings field, and a later verification pass against two other Gather pages did not corroborate it.
    • Gather Classic (1.0), 24 hours for its "Guest Check-In" access grant. Confirmed verbatim — but it is legacy documentation for a feature that does not appear in the 2.0 help centre, so it describes a product generation that no longer exists.
    • Everyone else: nothing. Zoom, Teams, Meet, Webex and Daily document no durational admission window at all.

    So the number is genuinely ours. Treat Gather's 12 hours as a weak sanity check, not a precedent to follow, and pick from expected usage — how long should someone be able to step out and come back without asking twice? The shape is settled — sliding, not absolute (§12); only the number is open. Nothing about the design's safety depends on the value: the invite is revocable and the visitor is kickable at any moment regardless.

  • denied is terminal for that request.

  • The API status vocabulary is computed, not stored. A row that is pending with expires_at passed reads as expired; so does an approved row past approved_until, and so does a pending row whose invite was revoked. Nothing writes expired — the column keeps the value legal for a future job that might materialise it, but no code path does today.

  • A revoked invite reports expired on the status endpoint rather than an error — the visitor is not entitled to distinguish "a member revoked the link" from "your request lapsed", and folding it into a terminal status keeps the client's state machine at four branches instead of four-plus-an-error-path.


6. Data model

Three tables, one migration (plus visitor_activity_metrics in §13, owned by metrics). Offices live in the offices table — renamed from the legacy private.rooms by the sweep this plan depends on (§17 S0); every office FK below points at offices(id), the BIGINT surrogate, never at the public UUID. Until that sweep lands the names are rooms / rooms.id; nothing else changes.

Every FK in this feature is deletable — no ON DELETE RESTRICT anywhere (decided 2026-08-17). Two product constraints force it: an office must stay deletable, and any datum must be obliterable in potency. RESTRICT contradicts both by construction — it makes the parent undeletable in order to protect the child. §13's research memo records the evidence; the short version is that the house already decided this in migration 000033 (recordings), and no surveyed vendor makes an operational object undeletable to protect billing data.

private.visitor_sessions — the pseudonymous, browser-scoped principal (§6b)

id, public_id UUID UNIQUE
secret_hash BYTEA UNIQUE -- SHA-256 of the cookie value
claimed_by_user_id BIGINT NULL REFERENCES private.users(id) ON DELETE SET NULL
claimed_at TIMESTAMPTZ NULL
created_at, last_seen_at, expires_at

No CHECK pairing claimed_by_user_id with claimed_at. The earlier CHECK ((claimed_by_user_id IS NULL) = (claimed_at IS NULL)) is incompatible with SET NULL: erasing the claimed user would null one half of the pair, violate the constraint, and abort the delete — the RESTRICT behaviour we just refused, arriving as an unreadable constraint error. claimed_at survives alone and means this session was claimed, at time T; whether the claimer is still resolvable is a separate question the FK answers. Deliberately carries no org_id and no office_id. A session spans offices and organizations; the tenant boundary is re-established on the join request, which is where org_id and office_id live. That absence is the table's defining property, not an omission — see §6b for what it costs.

private.visitor_invitesid, public_id UUID UNIQUE, org_id BIGINT NOT NULL REFERENCES private.organizations(id) ON DELETE CASCADE, office_id BIGINT NOT NULL REFERENCES private.offices(id) ON DELETE CASCADE, secret_hash BYTEA UNIQUE (SHA-256, indexed), created_by_user_id BIGINT NULL REFERENCES private.users(id) ON DELETE SET NULL, arrival_mode (CHECK office/conference), label VARCHAR(80) NULL (§6a — member-supplied, never shown to the visitor), requires_approval BOOLEAN NOT NULL DEFAULT TRUE, expires_at TIMESTAMPTZ NULL (NULL means never), revoked_at, max_uses INT NULL, use_count INT NOT NULL DEFAULT 0, timestamps.

A table constraint forbids requires_approval = FALSE AND expires_at IS NULL — the one combination that turns the doorbell back into a key (§1). In the schema, so a future caller cannot bypass it. expires_at IS NULL stays legal on its own: a link that never expires but always needs a human to admit is still a doorbell.

office_id is ON DELETE CASCADE, and the invite genuinely dies with the office. An invite is a capability naming one office (below); once that office is gone the capability means nothing, and keeping the row would leave a live secret resolving to an absent target — a thing that must fail closed, so better not to exist. GitHub is the one product that documents this case at all, and it draws the same line: "Restoring a repository will not restore team permissions" — grants die with the container even when the container itself comes back.

private.visitor_join_requestsid, public_id UUID UNIQUE, session_id BIGINT NULL REFERENCES private.visitor_sessions(id) ON DELETE SET NULL, invite_id BIGINT NULL REFERENCES private.visitor_invites(id) ON DELETE SET NULL, org_id BIGINT NOT NULL REFERENCES private.organizations(id) ON DELETE CASCADE, office_id BIGINT NULL REFERENCES private.offices(id) ON DELETE SET NULL, display_name VARCHAR(100) (unverified text, validated at write), livekit_identity VARCHAR(64) (visitor-<uuid>), status (CHECK pending/approved/denied/expired), decided_by_user_id BIGINT NULL REFERENCES private.users(id) ON DELETE SET NULL, decided_at, auto_approved BOOLEAN NOT NULL DEFAULT FALSE, expires_at, approved_until, token_mint_count INT NOT NULL DEFAULT 0, timestamps.

Every FK on this table points sideways and is nullable — except org_id, which points up and never is. Deleting an office cascades away the invites and nulls invite_id and office_id here; erasing a visitor nulls session_id; erasing a member nulls decided_by_user_id. What is left is a row that still knows its tenant, its outcome and its timestamps. That is the intended terminal shape, not a degradation: the operational pointers are the erasable part, the tenant key is the durable one. Revised by §20c (2026-08-17): that is true when the erased person is a member, and insufficient when it is the visitor. display_name and livekit_identity survive every null described here, so erasing a visitor is a scrub of those columns, not a nulled FK — see §20c for the statement, the nullability it forces, and the two things it does not reach.

auto_approved is a fact about this admission, not a copy of the invite's setting: nobody decided this — the link did. It is what lets the attribution CHECK stay total once requires_approval = FALSE exists again, and the panel needs it anyway to distinguish "admitted by Marco" from "walked in". Snapshotting rather than joining is safe because invites are never edited — create, revoke, list — so it cannot drift from the invite that produced it.

There is no per-request secret_hash any more. The session cookie authenticates the browser and the request names itself by public_id; the server checks that the request belongs to the calling session. §15's protection is unchanged — "anyone learning a request id could poll and collect someone else's token" still fails, because knowing an id is not holding the cookie that owns it — but it now rests on one secret instead of N. This is forced anyway: one cookie cannot carry a per-request secret once a browser holds requests at several offices.

livekit_identity stays on the request, never on the session. Per-session it would put the same string in the room metadata, webhooks and recordings of different organizations, so two tenants comparing artifacts could learn the same person visited both. Per-request there is nothing to correlate. "Stable for the whole lifecycle" in §4C means the lifecycle of that request.

display_name stays on the request too. On the session it would be one name across every tenant, and editing it would rewrite history for organizations that hosted you under the old one. A name is a fact local to one admission — which is also why §15 forbids treating it as an identifier.

org_id is the tenant fence, not a read optimisation

Every table in this feature carries org_id BIGINT NOT NULL in its own right, redundantly with the chain that could derive it. §6 used to justify that as "denormalised so the pending list is one indexed read", which is true and is not the reason.

The reason is that invite_id and office_id become NULL. Deleting an office cascades the invites away and nulls both pointers on the request; from that moment a derived org_id would be underivable, and the row would be data belonging to nobody — unbillable, uncollectable by tenant, and, worst, not excludable from another tenant's queries. org_id is the one column in the whole chain that can never be null, because it is the last thing standing once everything else has been orphaned.

§6b's central invariant already depends on this without saying so: "an organization must never learn that a visitor also visited someone else" holds because every org-facing query reaches visitors through visitor_join_requests.org_id rather than walking out to the session and back. Route that through invite_id → office_id → org instead and the fence evaporates precisely when those FKs are nulled — after an office deletion, which is exactly when nobody is looking.

Its action is ON DELETE CASCADE, following migration 000033 verbatim: "deleting an organization is the legitimate end of the billing relationship." It is the only CASCADE pointing upward in this schema, and it is also the tenant-obliteration mechanism, free: removing an organization removes every visitor row it ever produced, across all four tables, with no sweep and no ordering to remember.

The rule in one line: org_id points up and is never nulled; every other FK points sideways and is always nullable.

Indexes: (office_id, status), (session_id), (org_id), visitor_sessions(claimed_by_user_id) (the claimed-user lookup of §6b runs on a read path), unique on visitor_invites.secret_hash, visitor_sessions.secret_hash, and every public_id.

Attribution is erasable by design — reversed 2026-08-17

This section previously argued the opposite, and the reversal is deliberate. It held that created_by_user_id NOT NULL plus ON DELETE RESTRICT on both attribution FKs was the right outcome, because it made a creator or decider impossible to hard-delete and so forced erasure to anonymise the user row in place. The compliance posture became something the database enforced rather than a convention someone could forget.

That is a coherent position and it is not the one this product takes. Any datum must be obliterable in potency, which RESTRICT denies by construction. And the house had already decided it the other way: migration 000033 moved recordings.initiated_by off RESTRICT for this exact reason — "Deleting a user who ever recorded is no longer blocked." Holding both positions in one schema was the actual defect.

So: created_by_user_id and decided_by_user_id are both nullable, both ON DELETE SET NULL.

What this costs, stated plainly rather than discovered later. Hard-deleting a member destroys the audit fact of which member created a link or admitted a visitor. NIST AU-3 ("identity of any individuals … associated with the event") and ISO 27002 5.18 are weakened at exactly that moment. GDPR Art. 17(3)(b) and (e) permit retaining the fact — they do not require it — so discarding it is legal and is our choice, not an obligation. The mitigation is operational, not schematic: the preferred path for honouring an erasure request stays anonymise-in-place on private.users (name and email scrubbed, row alive), which obliterates the personal data while leaving attribution resolvable. SET NULL is the escape hatch that guarantees deletion is always possible, not the routine. Atlassian ships the anonymise-in-place shape under a page titled Right to erasure; GitHub reattributes to a "ghost" account. No regulator or vendor prescribes an FK pattern here — the legal permission is documented, the schema is ours.

Consequence for the read path is unchanged and now applies more often: every DTO exposing a creator or a decider must render a missing user without breaking. Discord's own inviter field is optional for the same class of reason.

The constraints, and the rule that produced them

No CHECK may reference a column that erasure will null. This is a general schema rule, worth stating once because it recurs at every table with attribution: a CHECK plus ON DELETE SET NULL on the same column is a contradiction the database resolves by aborting the delete. You get RESTRICT semantics with an unreadable constraint error, which is strictly worse than either choice made honestly.

The previous constraint set violated it twice — CHECK ((decided_by_user_id IS NULL) = (decided_at IS NULL)) and a second CHECK requiring decided_by_user_id IS NOT NULL. Both are replaced:

CHECK (status NOT IN ('approved','denied') OR decided_at IS NOT NULL OR auto_approved)
CHECK (NOT auto_approved OR status <> 'denied')

The attribution invariant survives, anchored on the timestamp instead of the identity:

Every admission records that a human decision occurreddecided_at when a member decided it, auto_approved when the link did. Which human is carried alongside for as long as that person exists, and is erasable by design.

decided_at is a timestamp, not personal data, and survives every erasure path. The auto_approved disjunct still does the job it was added for (2026-08-07): a knock on a requires_approval = FALSE invite produces an approved row nobody decided, and without the disjunct the constraint would reject it outright. And an auto-approved row can never read as denied, because nothing ever refused it.

Anything reading "who let this outsider in" still falls back through the invite when auto_approved (§1) — and must now also tolerate a null on both sides, rendering "a member, since deleted" rather than an empty cell.

Two increments must be conditional, both following IncrementAvatarUploadTriesIfBelow (:execrows, WHERE … < max): use_count against max_uses, and token_mint_count against its cap. A read-then-write lets two concurrent callers both pass a max_uses = 1 invite.

use_count counts admissions, not knocks (decided 2026-08-07). A member setting max_uses = 1 means one person gets in, not one person may ring the bell once — under knock-counting, a mis-clicked Deny or a re-knock after a dropped connection would burn the invite.

So the increment lives wherever the admission happens, which is one of exactly two places and never both for the same row: the approve transaction (§4F) on an approval-required invite, or the knock itself (§4C) on an auto-approving one. Re-entry inside approved_until consumes nothing, because it passes through no new admission — correct, since someone who drops and returns is not a second person. A kick does not refund the use: it was consumed at admission, and refunding would let a member reclaim capacity by ejecting people, which would stop the cap meaning "how many people did I let in".

The abuse caps stay on the knock, where §4C puts them, and they need no columns at all: concurrent pending is a COUNT over the existing (office_id, status) index, and knocks-per-window is a count over created_at on the same table. Knocks are already rows.

The approve transition is likewise conditionalUPDATE … WHERE status = 'pending' AND expires_at > now(), :execrows. Zero rows means someone else decided first, and the loser gets visitor-request-already-decided, a 409 (§14) — revised 2026-08-07 from an earlier success-shaped body. Losing a race is a conflict, and the member who clicked deserves to be told so rather than handed a success they must inspect.

Order matters inside the approve transaction: increment use_count against max_uses first, then flip the request. Reversed, an exhausted invite would leave a member having approved someone they cannot admit.

There is deliberately no poll_count. With the token mint split off, a status read discloses only the status, so a per-credential poll cap protects nothing expires_at does not already bound — and counting in the database would turn a cheap indexed SELECT into a write per poll per waiting visitor, making volume abuse more expensive for us rather than less.

Where the allowlist lives. visitor_invites.office_id is the allowlist: one invite, one office. §16 keeps multi-office invites out of scope, and the reason had to be rewritten when sessions arrived (2026-08-07): it used to be "an allowlist larger than one needs a durable principal, and a visitor has none by construction — a session is what this design refuses". §6b introduces exactly that principal, so the old argument is now false, and a review that spotted it would have been right to knock down the conclusion along with it.

The conclusion survives on different ground, and it is a product one: an invite names one office, and a person accumulates offices by being admitted to each. A member handing out one link that opens five offices is not describing a visitor but a temporary collaborator, and the honest answer there is a seat. What sessions changed is that the refusal is now a choice rather than an impossibility — worth saying, because the two read very differently to whoever revisits this.

One invite reaches one office, and that is deliberate, not a gap. A member who wants to give a visitor several offices is describing a temporary collaborator, not a visitor — and that person should take a seat. The intern is the canonical example. This is the same reasoning as the paragraph above, stated from the product side rather than the schema side.

6b. The session, and claiming it (2026-08-07)

One browser, one session, many offices across many organizations. A person admitted to an office of org A and another of org B holds one session and two join requests. The session is what makes that expressible; before it, the cookie was a single request and the model could not represent a second one.

Claiming is how a session acquires a verified identity. An explicit POST — never automatic — presenting both the visitor session cookie and the WorkOS access-token cookie. It stamps claimed_by_user_id and claimed_at, and it is one-way and terminal: a session is claimed once, by one user, forever. A second person on the same browser opens a new session. Anything else would make the attribution of past admissions mobile, which is precisely what §6's CHECK constraints exist to prevent.

The claim is retroactive, and that falls out of the model rather than needing a backfill. Because the join is request → session → claimed_by_user_id, every request of that session resolves to the claimed user the moment the stamp lands — past admissions included. Nothing is rewritten.

Claiming adds identity; it never removes visitor status. A user who is already a member of a visited organization stays a visitor there — their admissions, their consumed minutes and their audit trail are unchanged and remain attributed to that org as visitor activity. What changes is that the org now sees a verified person instead of typed text. This is deliberately not a transition: there is no termination of live accesses, no cancellation of pending requests, and therefore no system-authored decision that would need a decider it does not have (§6). The claim is a single write.

The session secret must rotate in the same transaction. The claim is a privilege elevation — the session goes from self-asserted text to a verified person, retroactively — so an unrotated secret is textbook session fixation: whoever planted or intercepted the old value on a shared machine would hold a session that has since become trusted. Rotation on privilege change does not contradict §8's "set once at the knock, never on the poll", which forbids gratuitous rotation on a pure read; the claim is neither gratuitous nor a read.

What each side sees — the invariant that matters most

WhoSees
The claimed userall their visitor accesses, across every organization — their own data
An organizationonly the rows for its own offices

An organization must never learn that a visitor also visited someone else. The session is the one cross-tenant object in this design, so it is the one place a careless join leaks between tenants — every org-facing query reaches visitors through visitor_join_requests.org_id, never by walking out to the session and back. Worth an adversarial test, because the mistake is a single missing predicate and produces no error.

Minimal disclosure on the org side: a claim surfaces the verified name and a verified flag, not the user's email and not their organizational affiliation. If the claimer is already a member of the visited org, that org has the rest already; if they are not, it has no business acquiring it.

Surfacing the accesses on the user — settled: a field on shared/user.User

A claimed user's visitor accesses are derived, never stored — the data is user → sessions[] → requests[], and one user may have claimed several sessions from several browsers, so the list aggregates across them. A denormalised slice on the user row would duplicate and drift, and the repository layer forbids that shape anyway.

It rides internal/features/shared/user.User, the session-hydration payload GetCurrentUser returns flat. An earlier draft of this section argued against that on the grounds that the payload is org-scoped while visitor accesses are cross-org. That argument was wrong: User already carries Organizations []organization.OrganizationMembership beside CurrentOrganizationID, so it is already the caller's cross-organization self-view. One new field, and the import pattern is the one shared/user already uses for shared/organization and shared/presence:

VisitorAccesses []visitoraccess.Access `json:"visitorAccesses,omitempty" doc:"Offices this caller was admitted to as an unregistered visitor, across every organization. Populated only from visitor sessions the caller has claimed; absent for everyone else."`

omitempty for consistency with Organizations, the analogous field in the same struct — the house rule breaks naming and shape ties in favour of the surrounding package, and for the overwhelming majority of users who never claim anything the field simply does not appear.

The seam sits on the user service, not the api handlers: getinfo.go calls h.deps.Lookup.GetInfo(...) and receives an already-assembled *user.User, so the service is what composes it. user defines the role interface it needs — VisitorAccessLister.ListVisitorAccesses, keyed by repository.CallerID and nothing else — and the visitor service satisfies it as a concrete type, wired at the composition root, the same shape as recording.OfficeMetadataUpdateroffice.Service.

The wire type lives in internal/features/shared/visitoraccess because two features serve it: visitor owns the data, user surfaces it.

{
"id": "user_01J…",
"displayName": "Giulia Rossi",
"currentOrganizationId": "org_acme",
"organizationMemberships": [ { "organizationId": "org_acme", "role": "admin" } ],
"status": "online",

"visitorAccesses": [
{ "requestId": "3f2a…", "orgName": "Northwind Studio", "officeId": "office_7c1…",
"officeName": "Sala Riunioni", "status": "approved",
"admittedAt": "2026-08-06T14:02:11Z", "approvedUntil": "2026-08-07T02:02:11Z" },
{ "requestId": "9b4c…", "orgName": "Acme", "officeId": "office_2ee…",
"officeName": "Design Room", "status": "expired",
"admittedAt": "2026-08-01T09:30:00Z" }
]
}

Two lists that answer different questions and are deliberately not disjoint: one names the organizations you belong to, the other the offices you were let into — including, as the second entry shows, an office of an organization you belong to.

approvedUntil needs its wire documentation spelled out, because every reader's first guess is wrong. It is not a visitor lifetime and not a disconnection deadline:

ApprovedUntil *time.Time `json:"approvedUntil,omitempty" doc:"Deadline for returning to this office without knocking again. It never disconnects a connected visitor — a live session outlives it — and it moves forward on every re-entry (§12). Absent unless status is approved."`

Three deadlines exist and each answers a different question; conflating them is the most likely misreading of this whole model:

BoundsAnswers
visitor_invites.expires_atthe linkuntil when this link lets anyone knock
visitor_join_requests.approved_untilyour return to one officehow long I may be away and still walk back in
visitor_sessions.expires_atthe browser's identityuntil when this browser is still "me"

The third is not an access grant at all — it is identity continuity, the thing that holds a person's accesses together and makes a claim possible. See §12 for why the second must never outrun it.

When the claimer was a visitor to their own organization

Giulia is an admin of Acme and holds a visitor admission to an Acme office. Nothing special happens, and that is the design working rather than a case slipping through:

  • She keeps it. Claiming adds identity and never removes visitor status, so the row, its minutes and its audit trail stay attributed to Acme as visitor activity. No termination, no cancellation, no system-authored decision that would need a decider it does not have (§6).
  • It was already redundant, because office access is org membership (§9: GetOfficeByOfficeIDAndWorkosOrgID is the entire fence and ListOffices is granted to every role but guest). A member never needs a visitor link to their own office, so this state means she was admitted while signed out or before holding a seat, and then claimed.
  • So it self-resolves. The visitor access lapses at approved_until and is never renewed, because she enters as a member from then on. Nothing has to clean it up.
  • The member path must be preferred while both exist. A visitor token is strictly less privileged — CanUpdateOwnMetadata: false, publishable sources enumerated, arrival mode fixed — so entering her own office through a stale visitor access would silently degrade her session. There is no escalation risk in the other direction, only a worse experience.
  • The panel should say so. An admin who sees a colleague listed among the visitors of their own office, with no explanation, will file a bug. The row is a member — surface it.
  • Accounting wrinkle worth knowing: her minutes sit in visitor_activity_metrics while her member minutes sit in participant_activity_metrics (§13). Not double counting — different sessions at different times — but any "distinct visitors" figure for Acme will include a member of staff unless it excludes claimed sessions whose user holds a membership in that same org.

And the invariant still holds throughout: claiming tells Acme who Giulia is, and tells Acme nothing about her Northwind admission.

6a. Which shape the invite takes — settled: C, free multiple (2026-08-07)

Three shapes were considered. They differ by exactly one thing: which uniqueness constraint the table carries. Everything else in §6 is identical across all three. C was chosen and is what §6 specifies; A and B are recorded below so the reasoning survives review.

ConstraintA member fetching a link getsNeeds an invite list UI
A — per memberUNIQUE (office_id, created_by_user_id)their own, always the same oneyes
B — per officeUNIQUE (office_id)the office's one link, sharedno — one field and a Reset button
C — free multiplenone (what §6 specifies today)whatever they ask foryes

What the industry actually does. No product ships A. Discord — the model we tested and liked — is C, not A: POST /channels/{id}/invites takes a unique parameter, documented verbatim as "if true, don't try to reuse a similar invite (useful for creating many unique one time use invites)", default false. So the caller chooses reuse-or-mint per call, and one member may hold many live invites on one channel with different expiries and caps. The docs do not define what "similar" means — a documented gap. Slack's admin page lists several links each with its creator and creation time (medium confidence; Slack documents no get-or-create semantics anywhere). Gather's Guest Invite Links carry per-link expiry chosen at creation and "multiple can coexist" — but revocation there is per guest, not per link, and every Gather guest-admin page carries the Classic (1.0) banner, so it is legacy documentation. B's closest analogue is Notion's "one secret link per workspace", which has no expiry, no use cap and no creator — the fields a singleton does not need.

The decisive structural fact: B changes the meaning of every per-invite column. arrival_mode, requires_approval, expires_at, max_uses and label are per-invite in §6, and §4A promises the member chooses them per link. Under B they collapse into office-level settings — one arrival mode per office, one expiry for everyone, a label that names nothing in particular, and a Reset that silently invalidates every link every colleague has already sent. B is not a simplification of this design; it is a different design, and adopting it means rewriting §4A, §6 and most of §7.

A costs use cases and saves nothing once the panel exists. Its only advantage over C is that lazy-init can never collide between two members. But that advantage evaporates the moment there is an invite list — which §7 already ships and which the admin panel now requires. What A costs is real: one link per member per office means a member cannot hold a capped one-shot link for Thursday's client and a longer one for next week's candidate.

Compliance does not decide this. The granting act is the admission — which is decided_by_user_id when a member approves, and created_by_user_id on the invite when the link admits by itself (§1). Either way a member's name sits on the grant, which is what NIST AU-3 and ISO 27002 5.18 ask for, and all three shapes carry both fields identically. Choose on product grounds.

Recommendation: C, which is what §6 already says. A is C plus one index, so C is reversible into A by adding a partial unique constraint later, while B is a migration. If the one-link-per- member policy is wanted for launch simplicity, enforce it in the service layer over C's schema and relax it without touching the database.

No dedup on creation — decided against Discord's default (2026-08-07). Discord's unique: false reuses an existing invite whose parameters match; empirical testing confirms the dedup key is the parameter combination, not the requester (same TTL + same use cap returns the same link; a different TTL mints a new one). Discord's docs never define "similar", so this is measured, not documented. We do not want it. The dominant case here is a link per intended person — a recruiter with ten candidates wants ten distinct single-use links, and parameter-dedup would hand back the same one ten times, so the first candidate exhausts it and the other nine are locked out. Discord itself concedes the point: unique: true is documented as "useful for creating many unique one time use invites", which is exactly this case. Dedup is the right default for Discord because its dominant case is one link pasted publicly; ours is the opposite.

Two further notes. Dedup would have been mechanically impossible as §6 stands anyway: it keys on a duration (max_age) while we store an instant (expires_at), so "one week" requested at 10:00 and at 14:00 produce different keys and nothing ever matches — implementing it would require storing the requested TTL alongside the absolute expiry. And the whole question only arises under implicit creation: with an explicit create action and a list to copy from, there is nothing to deduplicate.

label VARCHAR(80) NULL — adopted (2026-08-07), and close to required by the case above. Ten single-use links created in the same minute by the same person with identical parameters are ten identical rows in the panel; when candidate four withdraws, there is no way to tell which row to revoke. The visitor's own typed name cannot resolve it — that is unverified text (§15) supplied after the fact, and possibly never, if the link goes unused. So in the single-use pattern the link is the only trustworthy binding between a row and a human, and an optional creator-supplied label ("Mario Rossi — backend interview") is what makes the invite list usable at all. One nullable column, member-supplied, never shown to the visitor, validated like display_name (§15) since it renders in members' UI. Design it anticipating that bulk creation — ten links in one call — is the natural follow-up ask, and would arrive as a list of labels under the house batch rules.

Nothing bounds how many invites exist, and that is deliberate. §15's caps are all per invite (concurrent pending, knocks per window); this shape adds no ceiling on invite count, where A and B would have got one free from their uniqueness constraint. The bound is not restored in the application — abnormal volume is observed and acted on commercially rather than refused by the backend. §15c records why, and what stays unprotected as a result.

The honest cost of C, recorded so review does not discover it later: prevention gets weaker as links multiply. §12's RevokeVisitorInvite closes one link; the same person may return through another, and revocation removes nobody in any case (§12). Every competitor's sticky tier is person-shaped (Kumospace's Block Guest, Gather's Ban); ours is person-shaped too — KickVisitor — but it is reactive, not preventive, because §16 is right that a ban list is meaningless against an unidentified principal. What keeps this acceptable is §1: returning requires a fresh approval by a member inside the office, so the human gate is re-applied every single time. Under B this tension does not exist, which is B's one genuine advantage.


7. API surface

Every operation is POST and answers 200 with a JSON body. Bodyless successes return {} via emptybody.Empty.

Member-facing — Register (cookie auth) + one of three permissions

PathOperationPermissionIn → out
/offices/visitor-invites/createCreateVisitorInviteManageVisitorInvitesofficeId, arrivalMode, requiresApproval, expiresAt?, maxUses?, label? → inviteId, url
/offices/visitor-invites/revokeRevokeVisitorInviteManageVisitorInvitesinviteId → {}
/offices/visitor-invites/listListVisitorInvitesManageVisitorInvitesofficeId, status?, limit, offset → {data, page} (never secrets)
/offices/visitor-requests/listListVisitorJoinRequestsAdmitVisitorofficeId, status, limit, offset → {data, page}
/offices/visitor-requests/approveApproveVisitorJoinRequestAdmitVisitorrequestId → outcome
/offices/visitor-requests/denyDenyVisitorJoinRequestAdmitVisitorrequestId → outcome
/offices/visitors/kickKickVisitorKickVisitorrequestId → {}

The two list operations land on different permissions, and the criterion is what each list is for. ListVisitorJoinRequests is the work queue of AdmitVisitorstatus=pending drives the approve/deny UI and the realtime re-baseline (below), so a member who may admit but may not read the queue holds an inert permission. ListVisitorInvites is the link inventory, which is management work. Same house split as ListInvitations sitting beside RevokeInvitation rather than beside InviteUser.

Create and revoke deliberately share one permission. The member tier splits them, so the precedent for going finer exists — but here it would produce a role that mints links and cannot kill them, which is a worse posture than either alternative, and §6a already records that the sticky removal tier is weak precisely because it is link-shaped. Whoever creates must be able to revoke.

Defaults in rbac.go (settled 2026-08-17):

PermissionAdminModeratorMemberGuest
ManageVisitorInvites
AdmitVisitor
KickVisitor

Admitting a visitor is the job of whoever is in the office, not of whoever administers the organization — so it reaches RoleMember. Deciding who may hand out links to outsiders is organizational policy, so invite management does not. RoleGuest is false for all three, without exception: the registered-guest tier must never gate the unregistered one.

KickVisitor is a separate permission but defaults to the same roles as AdmitVisitor, and that pairing is deliberate. Splitting them makes an asymmetry expressible — an organization that wants admission and removal in different hands can have it — without making it real by default, which would leave a member able to admit the wrong person and unable to undo their own mistake. The dial exists; it starts centred. It also matches the industry (§12): Gather lets any member kick and restricts only Ban; Kumospace ships Remove and Block as distinct actions.

requiresApproval is back on the create input and on the preview output (2026-08-07). The visitor is told before typing their name whether they will wait or walk in — a pre-join screen that promises a wait and then admits instantly is a worse experience than either honest one, and the field costs nothing to expose since the link already encodes the behaviour. The cross-field rule — no approval requires an expiry — belongs in the Huma input resolver, which is where the house convention puts constraints a schema cannot express, with the table constraint (§6) as the backstop.

Create returns url and not the raw secret. The url is …/join/<secret>, so returning both would put the secret twice in one response body — doubling what a log, an error report or a clipboard can leak — for nothing. The /join/<secret> path shape is a contract the backend owns, because §4B's history.replaceState() scrubbing depends on it.

One list operation, filtered by status — not two. ListVisitorJoinRequests serves both consumers: status=pending drives the approve/deny UI and the realtime re-baseline, while approved / denied / all drive the admin panel's columns. LiveKit presence is reconciled only when the filter admits approved — a pending visitor is outside LiveKit by construction (§3), so the hot path never pays for a call to the video provider that could only return nothing.

Pagination is the house one, and the filter is therefore server-side. internal/features/shared/page, the frozen { data, page } contract, OffsetPage (the cursor is reserved for keyset-by-design sources), limit 1–100 defaulting to 20 — copy SearchOffices. Filtering client-side is not a style choice here but a correctness bug: it would filter only the page in hand, reporting "3 denied" out of a page of 20 when there are 40 across ten pages.

Visitor-facing — capability auth, one prefix

PathOperationCredentialIn → out
/visitor-access/previewGetVisitorInvitePreviewinvite secret (body)orgName, officeName, arrivalMode, requiresApproval. Pure read — creates nothing
/visitor-access/requestCreateVisitorJoinRequestinvite secret (body)displayName → requestId, status, realtimeToken?, connectURL? (+ Set-Cookie: session)
/visitor-access/sessionGetVisitorSessionsession cookie(empty) → session, claimed?, realtimeToken, connectURL, accesses[], nextPollInSeconds
/visitor-access/tokenCreateVisitorOfficeTokensession cookierequestId → token

GetVisitorJoinRequest is absorbed by GetVisitorSession. It used to earn three jobs — the baseline after the socket opens, the fallback poll, and the realtime-token re-issue. Under §6b all three become session-level: the realtime token belongs to the session (the channel is visitor:{sessionPublicId}), the baseline wants the state of every access rather than one, and one poll loop beats one per office. It stays a pure read with no writes — signing is not persistence.

CreateVisitorOfficeToken now names its request in the body. Before, the cookie was the request; now the cookie is the session and the request must be identified. This is the easiest signature change in the whole plan to forget.

The route is /visitor-access/token, mirroring the member /offices/tokenCreateOfficeToken. The earlier /visitor-access/office-token/create did not match any sibling.

Liveness moves from the middleware to the payload. §8's typed RequireLive/AllowDead argument existed so the status endpoint could answer cleanly for a revoked invite. With sessions the middleware asks one question — is this session live — and a dead access is simply data: each entry in accesses[] carries its own computed status (§5). The policy argument has nothing left to distinguish.

Claimer — both credentials at once

PathOperationCredentialsOut
/visitor-access/session/claimClaimVisitorSessionsession cookie + WorkOS access-token cookieclaimed session + Set-Cookie: rotated session secret (§6b)

The path is forced, and it works: the visitor cookie is Path=/visitor-access, and the member access-token cookie is Path=/ (pkg/token/manager.go:81), so /visitor-access/session/claim is reached by both without widening either. It is the only operation on the whole surface wanting two credentials.

The claimed user's own viewListVisitorAccesses returns the caller's visitor admissions across every organization (§6b). It is the single deliberately cross-tenant read in this feature, and it is safe because its key is the caller's own id — repository.CallerID, never a parameter — so the only person who can see the list is the person it belongs to. It is not its own route: §6b settles it as a field on shared/user.User, so it reaches the wire through GetCurrentUser and the role interface user defines for it.

A single top-level prefix is what makes the edge policy expressible: Cloudflare rules key on path, so /visitor-access/* is where per-IP limiting, bot filtering and a tighter WAF posture attach. Tag the operations apitags.VisitorAccess so the generated contract groups them and the frontend can see which operations a member client should never call.

The mint is a separate endpoint from the member's /offices/token, and must stay so: create-office-token declares cookieAuth so a visitor cannot reach it; the fence asks a different question (org membership vs a live approval for this office); the grants differ (members keep CanUpdateOwnMetadata: true); and the member path calls CreateRoom to seed the metadata the Cloudflare worker routes by, which a visitor must never reach — a stranger knocking at a deleted office must not conjure it. What the two share is the seat check and the mint, behind the entitlement seam (§17 S4a); each resolver keeps its own fence and its own side effects, so the visitor path has no code path to CreateRoom at all rather than a boolean that could be flipped.


8. How visitor requests are authenticated

One security scheme, not two — visitorSession, apiKey in: cookie (revised 2026-08-07). The earlier design declared visitorInviteSecret and visitorRequestSecret as two cookie schemes, reasoning that a single scheme cannot tell a generated client which of two credentials an operation wants. That reasoning dissolves once the invite secret stops being a cookie:

The invite secret travels in the request body, never a cookie and never a header. It is read from the URL query on every page load and handed to the two operations that need it — preview and knock — and to nothing else. Three reasons, in order of weight:

  • Two invite links in two tabs collide as cookies. Under §6b a person may hold accesses at several offices; opening a second link would overwrite the first, and the first tab would knock at the wrong office. A body field has no such shared namespace. This alone settles it.
  • A cookie would give the secret persistence we deliberately remove. §4B scrubs it from the URL with history.replaceState(); writing it straight back into the browser undoes that. The session cookie exists because the session must persist — the invite secret must not.
  • As a body field it is typed in the generated contract and validated by Huma struct tags as the house rules require, with nothing added to the CORS allowlist. (§17 S2's X-Visitor-Secret header is dropped with it; the two clauses contradicted each other.)

The invite secret is still a real gate and is not replaceable by the session: they answer different questions — "may I ring this doorbell" versus "which browser is this". Someone holding a session but no invite must be able to knock nowhere. And it cannot be the public_id either (§16): a listable identifier and an unguessable capability cannot be the same value.

The existing plumbing already carries this — verified against the code, not assumed

The self-skip is the whole safety property, and it extends with no change to huma_auth.go. operationRequiresAuth returns true only for operations declaring cookieAuth; visitor operations declare visitorSession instead, so the global auth middleware skips them by construction. What §8 calls "what this buys by omission" is therefore mechanical rather than disciplinary: no identity is ever injected on a visitor route, so every IdentityFromContext there fails closed.

Registering the scheme is one entry in the existing SecuritySchemes() map, and the cookie name must come from the same constant the session manager sets — the reason cookieAuth uses token.AccessTokenCookieName is "so the spec can never describe a different cookie than the one the token.Manager actually sets/reads", and it applies unchanged:

const SchemeVisitorSession = "visitorSession"
// … {Type: "apiKey", In: "cookie", Name: visitor.SessionCookieName}

The one place a syntax slip becomes privilege escalation. ClaimVisitorSession needs both credentials, and OpenAPI expresses AND and OR by nesting depth:

// CORRECT — both required (AND: one map)
op.Security = []map[string][]string{{SchemeCookieAuth: {}, SchemeVisitorSession: {}}}

// WRONG — either suffices (OR: two maps)
op.Security = []map[string][]string{{SchemeCookieAuth: {}}, {SchemeVisitorSession: {}}}

The wrong form compiles, reads fine at a glance, and lets a bare visitor session claim a WorkOS identity. It must not be writable by hand: the claim route gets a dedicated registrar that builds the pair itself.

Two registrars, typed so the mistake cannot be made — the discipline RegisterWithPermission already states, "there is no way to register a permissioned route and forget the permission":

func RegisterWithVisitorSession[I, O any](rt Routes, op huma.Operation, h Handler[I, O])
func RegisterWithVisitorClaim[I, O any](rt Routes, op huma.Operation, h Handler[I, O])

Preview and knock are RegisterPublic, and that is forced. A Huma middleware runs before the handler and has no typed body, so it cannot validate an invite secret that travels in the body — the check moves into the service. §8's earlier objection to RegisterPublic ("four sibling endpoints sharing revoked/expired/use-cap checks; a fifth is one forgotten call from skipping them") has weakened on its own: the endpoints touching the invite secret are down from four to two, and both must resolve the invite anyway to do their job at all — the preview for the office name, the knock for the office and org. It is the first thing each does, not an extra step that could be forgotten. Still, one service function with two callersresolveLiveInvite(ctx, secret) returning the invite or the uniform visitor-invite-not-usable — never two copies of the same chain.

The session middleware owns no database. It extracts and hashes the cookie and calls a consumer-owned resolver interface; the concrete resolver is the visitor service, injected at the composition root — the same shape as recording.OfficeMetadataUpdateroffice.Service.

A distinct context key and a distinct type. huma_identity.go's unexported identityKey struct{} "guarantees no other package can read or collide with it"; the visitor gets its own visitorSessionKey struct{}, its own WithVisitorSession / VisitorSessionFromContext, and a VisitorSession type sharing nothing with AccessTokenClaims. Different key types cannot collide, and a distinct payload type is what stops the Spring Security failure mode below.

Set-Cookie collision: already handled. humaHeaderWriter.Add uses AppendHeader, not SetHeader, precisely "so rotating the access cookie never clobbers another Set-Cookie". That is our exact scenario: a claim arriving with an expired access token makes the auth middleware refresh and write one Set-Cookie, then the handler rotates the visitor session and writes another. Both must survive. Reuse the adapter — a hand-rolled one using SetHeader would drop one of the two, intermittently, only on expired tokens.

Slugged errors from middleware work: verified. huma.NewErrorWithContext is documented as the chokepoint and its list of inbound paths explicitly includes "the auth/RBAC middleware's huma.WriteErr calls". So huma.WriteErr(api, ctx, 401, "", apperr.ErrVisitorSessionNotFound) gets the full RFC 9457 treatment with its slug, provided the sentinel is in the apperr catalogue — an undeclared slug silently falls back to about:blank. No special plumbing; the same shape as the auth middleware. The invariant still binds: raw sentinels, never a pre-built huma.StatusError, which would bypass the seam along with instance stamping and Sentry capture.

CORS needs nothing. AllowCredentials is already true against an exact origin allowlist, so the session cookie travels as-is. The X-Visitor-Secret header §17 S2 planned to allowlist is dropped with the header itself.

The liveness policy argument is gone. It existed so the status endpoint could answer cleanly for a revoked invite; now the middleware asks only is this session live, and a dead access is data carrying its own computed status (§5, §7).

const VisitorRequestCookieName = "visitor_request" // never "access_token"

&http.Cookie{
Name: VisitorRequestCookieName, Value: secret, // 32B CSPRNG; server holds only SHA-256
Path: "/visitor-access", Domain: finalDomain, // same resolution as the member cookie
MaxAge: int((pendingWindow + approvedWindow).Seconds()),
HttpOnly: true, Secure: isProd, SameSite: http.SameSiteLaxMode,
}

A JWT would be actively wrong, not merely unnecessary. Statelessness buys nothing — every visitor call must read the database anyway for status, revoked_at, both deadlines, the seat count and the mint counter, all mutable. And a signed "you are request X, approved for office Y" survives revocation until its exp: plan.md §14 records that the deleted implementation failed at exactly this point, a JWE-sealed invite with "No jti, therefore no revocation". Putting the duration inside the credential recreates that flaw one layer down. The row carries approved_until; the secret is only a handle.

Do not confuse it with the realtime token, which is a signed JWT and legitimately so. They differ in every dimension that matters, which is exactly why both exist:

Capability cookieRealtime connection token
Formopaque 256-bit secretsigned JWT (HS256)
Reachesour backendthe broker
Readable by JSno (HttpOnly)yes — the client must hand it to centrifuge-js
Grantsthe right to ask — status, office tokensubscription to one channel, nothing else
Revocableyes, the row is consulted every timeno — but it expires in ~20 min and carries no authority beyond listening

The JWT is acceptable precisely because it authorises listening on one channel and nothing else. If it is stolen, the thief learns one stranger's admission outcome. Every irrevocable credential in this design is bounded that tightly.

Domain must be set, not host-only, because frontend and API are different origins under one site — the member cookie already does this. SameSite=Lax is proven in this deployment by that same cookie, including the Electron client. MaxAge deliberately outlives the pending window: a cookie outliving its row is harmless (the lookup fails into the uniform error), while one dying at 5 minutes would break re-entry inside approved_until. MaxAge is therefore derived from whatever that window is set to (§5, §12) and cannot be hard-coded — the two must move together or the cookie dies before the row it addresses. No HMAC wrapper — the value is 256 bits of CSPRNG, so forging it is guessing it.

Set once, at the knock. Never on the poll: rotating the credential mid-loop buys nothing and would put a Set-Cookie on the endpoint whose design property is being a pure read. Prefer cookie-only, with the body copy behind an explicit opt-in for non-browser callers (the e2e journey is the only one), so JavaScript never sees the secret at all.

What this buys by omission

Because a visitor never holds token.AccessTokenClaims, every existing handler calling IdentityFromContext already fails closed with ErrUnauthenticated. No sweep of existing handlers, and no endpoint becomes visitor-reachable by forgetting something — only by someone typing RegisterWithVisitorSession. VisitorSession is its own Go type, never AccessTokenClaims, and a visitor id is never a repository.CallerID. Spring Security's AnonymousAuthenticationToken is the documented cautionary tale: once anonymous and real callers share a shape, someone eventually forgets to check.

ClaimVisitorSession is the one operation holding both, and it is exactly why the two payloads must stay different types: it reads a real AccessTokenClaims and a VisitorSession in the same handler, and the compiler is what stops one being passed where the other belongs.

Two corrections to plan.md §8

  • RegisterPublic does not mean "no credential". organization/routes.go:169 registers accept-invite publicly with an invite token in the body and dedicated slugs. Public-plus- credential is an established pattern here. We still take the capability registrar, but on its real merits: four sibling endpoints sharing revoked/expired/use-cap checks, one of which mints a media credential — with RegisterPublic those checks live in four service bodies and a fifth endpoint is one forgotten call from skipping them.
  • The CSRF argument is unsound as written. "POST with JSON is not a simple request" is a known-unsafe generalisation — an attacker submits a form as text/plain, which skips preflight (CVE-2024-24816). We are safe for a stack-specific, verifiable reason: huma/v2@v2.38.0/api.go:331 resolves Content-Type against a format registry and returns ErrUnknownContentType for anything unregistered, and only JSON and CBOR are registered, so all three form-encodable types are rejected. Pin it with a test — it is a property of a dependency's configuration, not of the language.

9. How both sides are notified

Two namespaces, both Recovery: false, both fed by the transactional outbox.

NamespaceWho subscribesHowCarries
office:{orgId}:{officeId}members, on entering an officeon demand, subscribe proxy, pure org-segment string comparevisitor.join_request, visitor.join_request_resolved
visitor:{sessionPublicId}one browser, while it has anything pendingserver-side, pinned in the connection token's channels claim — the proxy is never involvedvisitor.request_resolved — that person's outcomes, one office at a time

The visitor's channel — now keyed by session (revised 2026-08-07)

It used to be visitor:{officeId}:{requestPublicId}, minted at the knock, with the rule "never a second channel". That rule was written when a cookie was one request. Under §6b one browser can be waiting at two offices of two organizations at once, and per-request naming would have forced exactly the second channel the rule forbids.

Keying on the session honours the rule instead of breaking it. One browser, one channel, for its whole life — and the event names which request it resolves. The token is issued and reissued from GetVisitorSession, not from a single knock, so it no longer dies with one request.

Nothing changes about what the channel discloses. The payload is still one person's own outcome and nothing else — the line §9 draws against Gather, whose unadmitted waiter receives a full space-state chunk including other users' records. A visitor still learns whether they got in, never who is inside, and never anything about the other organization they are simultaneously waiting at. That last clause is new and load-bearing: the channel is per-session and therefore cross-tenant, so its payloads must carry only what the recipient already knows about the office they knocked at — never an org roster, never a member's name.

The channel name still gates nothing, and that is still fine. With a server-side subscription the client never asks for a channel; authorisation happened when we signed the token. sessionPublicId is a UUID and globally unique, so dropping officeId from the name costs only log-grep convenience — and gains the ability to grep one browser's whole visit history instead of one office's slice of it. Worth writing down so nobody later "hardens" a name that was never a fence.

An auto-approved knock never opens this channel at all. It returns approved in the same response (§4C), so there is no wait to be notified about: no connection token, no connectURL, no socket. Those two fields are therefore optional on the knock response — present when the invite requires approval, absent when it does not — and a frontend that opens a socket unconditionally will hang waiting for an event that already happened. It is the single most likely integration mistake in this flow.

Token TTL covers the whole pending window — around 20 minutes against a 15-minute wait — so no refresh path is needed within one wait. The alternative, wiring centrifuge-js's getToken callback, works but fails silently if forgotten — the socket dies at ~10 minutes and the visitor simply stops receiving pushes, which in testing reads as "push doesn't work".

A session outlives any one wait, so the token is reissued rather than refreshed. The channel is now the session's (above), but the token still only needs to cover a wait: a visitor who returns days later calls GetVisitorSession, which hands back a freshly signed token for the same channel. That is one of the three jobs §7 gives it. The token stays short-lived — it is the one irrevocable credential in the design — while the channel is long-lived, and those two lifetimes are deliberately different.

Anonymous connections are bounded by the knock, not left open. A connection token is issued only to someone whose session holds at least one live pending request, which required a successful knock, which is capped per invite (§15). And because the token's sub is the session's identity rather than empty, Centrifugo's client.user_connection_limit applies per session — the per-source cap the broker otherwise cannot express for an anonymous caller. That is what makes this safe; without it the surface would be an unbounded anonymous socket sharing a global ceiling with paying members.

Keying the limit on the session rather than the request tightens it, which is a happy consequence of §6b rather than a cost: a browser waiting at three offices once counted as three allowances and now counts as one. The cap follows the thing that actually opens sockets — the browser — instead of the thing that merely justifies them.

The office channel

Why a channel and not a LiveKit data message. With a channel you never ask who is there — the broker's subscription state does the routing, so the fragile question ("which members are in this office?") is never asked on the notification path, and the visitor feature acquires no dependency on the webhook-fed room_presence mirror. The publish is enqueued through the transactional outbox, in the same transaction as the row it announces, delivered at-least-once with an idempotency key. SendData gives none of that: LiveKit does not buffer data packets server-side and SendDataRequest has no acknowledgement field, so a successful call confirms only that the server accepted the request. It would also carry an attacker-authored display name outside the contract pipeline — no generated TypeScript type, no promote guard, nothing to hang §15a's containment discipline on. The one payload that most needs the governed path is the one SendData takes off it.

Recovery is off on purpose, matching status: and typing:. The pending list has a REST baseline, and subscribing is the re-baseline trigger — a natural, client-driven moment that coincides exactly with "I am entering this office, give me the state". Replay would hand back a log including knocks already decided. Option A has no equivalent event; its client would have to invent a timer.

The subscribe-proxy check is a pure org-segment string compare, zero DB, exactly like handleStatusSubscription — because there is no per-office ACL in this product: GetOfficeByOfficeIDAndWorkosOrgID is the entire fence and ListOffices is granted to every role but guest. Office access is org membership, so the string compare is not an approximation of the authorization model, it is the model.

Three events in total, declared through clientevents.Define: visitor.join_request and visitor.join_request_resolved on the office channel, visitor.request_resolved on the visitor's own.

Everyone subscribed receives the knock, without filtering by AdmitVisitor — a channel has no per-subscriber filter. A member who cannot approve sees a knock they cannot act on, and the display name reaches them. Accepted; the only real fix would be a second channel or per-user publishes.

Why the waiting visitor is pushed to rather than polling

Re-examined 2026-08-06, because the first pass rejected it on a security ground that turns out to be weaker than stated. Three options:

ShapeVerdict
A. Short pollHTTP every 2–5s on /visitor-access/sessionFallback, retained
B. Own namespacevisitor:{sessionPublicId} (then per-request; rekeyed by §6b) pinned in the connection token's channels claimChosen
C. The office namespaceput the waiting visitor on office:{orgId}:{officeId}Never. That channel carries other visitors' display names and every knock and resolution in the office. A waiting stranger would receive all of it. Not a tradeoff, a leak

The security objection to B was overstated, and that correction matters. The original argument — a visitor on Centrifugo could reach status:{org}:{anyUser} through the proxy's zero-DB org compare — only holds if the token carries a real org id. The proxy already denies on an empty one: handleStatusSubscription refuses when request.MetaOrg == "". And a channel pinned into the channels claim is a server-side subscription that never consults the proxy at all. Centrifugo also supports anonymous tokens natively — an empty sub connects unless client.disallow_anonymous_connection_tokens is set. So B is buildable and the channel itself would be safe; only IssueConnectionToken's two guards would need relaxing.

What actually decides it is blast radius. Centrifugo exposes client.connection_limit (per node) and client.user_connection_limit (per known user, per node) — no per-anonymous-source limit exists. So under B, an unauthenticated stranger with the link opens persistent connections bounded only by a global ceiling shared with paying members' connections. Knock spam would degrade members' realtime, not just visitors'. OWASP's WebSocket guidance names exactly this failure mode and prescribes per-user and total connection limits — the first of which Centrifugo cannot express for an anonymous caller.

Under A, the same abuse hits one HTTP path that the edge throttles by prefix, and the damage is contained to /visitor-access/*. And the latency B buys — sub-second instead of up to 5 seconds — is worth nothing on a flow where a human is deciding.

Honest counter-evidence, because it exists. Teams' lobby participants are signalling-level participants: the Calling SDK models them as RemoteParticipant objects in an InLobby state on an existing Call, with lobby changes pushed over the Call Agent connection rather than polled. Zoom confirms waiting-room time does not count toward meeting minutes, consistent with a cheap pre-admission connection. So "signalling while waiting, media only after admission" is real, and B is structurally that shape.

Gather publishes nothing on transport, so it cannot settle this either way. (An earlier version of this section said Gather was a weak analogue because its check-in requires an account — that was wrong: the account requirement belongs to Guest Check-In, a Gather Classic (1.0) feature. In 2.0, guests are not required to sign in by default and the requirement is an admin option. Gather remains a full analogue for the unregistered visitor; it is simply silent on how the waiter is notified.)

Empirical verification — Gather, observed directly 2026-08-06

The vendor documentation could not settle whether a waiting visitor holds a connection or polls. A direct DevTools observation against a live Gather space did. This is stronger evidence than anything cited above, because it is measurement rather than documentation.

1. The unadmitted visitor holds an open WebSocket. With the screen still showing "Waiting to join — you'll enter automatically when admitted", the Network panel's Socket filter shows exactly one connection: gather-game-v2?spaceId=…, status 101, type websocket, state Pending, opened by the page bundle. No repeated status XHR anywhere.

Gather pushes; it does not poll. Combined with Teams' InLobby RemoteParticipant model, that is two products out of two where the waiter sits on a signalling connection before admission. The design choice is no longer an inference from ACS documentation.

2. But Gather's waiter already receives space state — and that is a mistake not to copy. The console shows the pre-admission client applying a full state chunk:

Applied FullStateChunk with 781 full and 0 delta patches
Patch groupings: {Connection: 1, Space: 1, UserAccount: 3, SpaceUser: 3, SpaceSettings: 1, …}

A stranger who has not been let in is receiving 781 patches including three SpaceUser records. Whatever those contain, the shape is the leak this plan rejects as option C: the waiter must receive only their own outcome, never the office's state. Gather's behaviour is a reason to scope our channel per request, not a pattern to follow.

3. There is a visible fence, and it is drawn where ours is. GET /api/v2/spaces/…/organization returns 403 Forbidden to the unadmitted visitor. Space state yes, organization data no — the same line this plan draws between "may enter this office" and "never sees the org directory".

4. Product note, outside our model. The member's prompt offers three actions — Decline, Running late, Meet here — not two. "Running late" is a deferral rather than a decision. Nothing in this plan expresses it; recorded in case it is wanted.

Still unverified: whether the waiting visitor holds any RTCPeerConnection. chrome://webrtc-internals while waiting would settle "signalling only, no media" completely.

Flip trigger for reverting to A: if Centrifugo cannot bound anonymous connections acceptably in practice, or if corporate proxies block the websocket often enough that the fallback becomes the common path rather than the rare one.

The one thing a visitor token must never carry — verified, not assumed

meta.org. The token's meta.org claim is the subscribe proxy's zero-DB authorization source, and handleStatusSubscription allows a subscription on a pure string comparison of that claim against the channel's org segment. A visitor token carrying a real org id would be positioned to subscribe to status:{org}:{anyUser} — every member's presence channel — with no database check in the way.

Left empty, the existing check request.MetaOrg == "" || request.MetaOrg != channelOrg denies fail-closed, for status: and typing: alike. So the guard already exists; the requirement is simply never to fill that field for a visitor. IssueVisitorConnectionToken (S2b) exists as a separate function precisely so this cannot be done by accident: the member issuer requires an org, the visitor issuer has no parameter for one.

The visitor also holds no channel but their own, because the subscription is server-side — nothing is subscribed that we did not put in the channels claim. And the token dies with the wait: after admission the visitor is in LiveKit and has no further use for the broker.


10. The approver must be in the office

Decided as a product rule, enforced server-side. A member who receives a knock, leaves, and then submits an approval is refused — the frontend hiding the notification is presentation, not enforcement.

  • Source of truth: LiveKitListParticipants at decision time. Broker channel presence is disqualified on principle (subscription is client-asserted, so a modified client could subscribe without ever joining); room_presence is disqualified by choice, being a webhook-fed mirror whose reaper is unwired.
  • Applies to deny as well as approve. Otherwise someone outside the office can refuse every knock in it.
  • And to KickVisitor (added 2026-08-17). The original draft was silent here, which read as an oversight rather than a decision: if presence is required to refuse someone entry, it is required a fortiori to eject someone already inside and possibly mid-conversation. Same slug, same check, same accepted race. The separate KickVisitor permission (§2, §7) governs who may do it; this governs from where.
  • Strip the dev-admin suffix. createofficetoken.go:92-95 appends __<unixnano> to one hard-coded dev account's LiveKit identity. The membership check must strip it or approval silently breaks for that account on the dev stack.
  • New slug approver-not-in-office, 403 — the endpoint already answers 403 for a missing permission and the frontend must render different copy ("rejoin the office to admit").
  • Accepted race: a member who leaves between the check and the transition still admits. One round trip, benign outcome, not worth a lock.

plan.md §9 assumed this property ("anyone who can approve is by definition in the room") and never enforced it. Now it is a check.


11. Platform additions

One field on livekit.VideoGrant: publishable sources, currently unmodelled, so the visitor grant can enumerate them explicitly rather than leaving a grant that widens itself on SDK upgrade.

One fix: RemoveParticipant must sever its not-found into apperr.ErrNotFound, exactly as GetRoomMetadata (client.go:221) and GetParticipantMetadata (:260) already do two functions above it. LiveKit returns "participant does not exist" when the identity is already gone, and today the client wraps it blindly and logs at ERROR. This is a prerequisite for the eviction sweep, and it repairs a live bug: organization/service/removemember.go:56 returns that error, so removing a member who is not currently in the supplied space fails the whole remove-member operation.

SendDataMessage is not needed — §9 chose the channel. plan.md §10's other claim, that UpdateParticipantPermissions is unnecessary, is correct for this design but is not LiveKit guidance: LiveKit's docs contain no "waiting room" or "lobby" concept at all, and its only admission-adjacent API operates on already-connected participants. It neither endorses nor warns.


12. Expiry and removal

Settled product decision (2026-08-05): a connected visitor is never removed on a timer. They stay as long as they stay. The organization that issued the link bears the connected time, and manages any overage commercially. Removal is a human action by that organization.

This aligns us with every product surveyed: Zoom, Teams, Meet, Gather and Kumospace all leave expulsion of a specific person to a host or admin, and none of them removes an individual guest on a timer, an inactivity clock, or a quota. Only whole-session caps exist (Zoom's 40 minutes, Teams' 30 hours, Meet's 60), and those end the call for everyone.

What approved_until does and does not bound

It bounds re-admission, not the live session:

  • A visitor who is connected stays connected. LiveKit re-issues refreshed tokens to connected clients itself, so the 10-minute TTL never forces them back to us.
  • A visitor who drops and returns needs a fresh token, and CreateVisitorOfficeToken refuses once approved_until has passed. They must re-knock.

Because the only thing the deadline gates is a request to us, lazy expiry is sufficient and plan.md §5's original claim stands: no background worker is needed for correctness. That claim was only false under the assumption — now dropped — that the deadline should also bound a live session.

Settled (2026-08-07): approved_until is sliding, not absolute. Set approved_until = now() + window on every successful mint. The clock then measures absence, not total time: a visitor who keeps using the office keeps re-entering, and one who disappears for longer than the window must ask again. Under an absolute cap, someone connected for ten straight hours gets bounced the first time their wifi blinks, which is a strange outcome for a person the org has been happily hosting all day.

So it is not "how long you remain a visitor". Nothing measures that. It answers one question — how long may I be away and still walk back in without knocking — and it moves forward every time you walk back in.

Two things a sliding deadline can outrun, and they are not the same problem.

It can outlive the invite that created it, and that is deliberate. A visitor admitted at 23:00 through a link expiring at 23:30 carries an approved_until far beyond the link's declared life. That is consistent with §5: neither a revoked nor an expired invite reaches a request that was already approved. Both mean "this link stops working", never "the people I already admitted must leave" — the difference between the two is only that one is a human act and the other is a clock, and neither is a removal. Reaching an admitted visitor is KickVisitor, deliberately and per person (§12). Recorded because the asymmetry looks like an oversight until you see it stated.

It must never outlive the session that carries it, and that one is a real trap. The re-entry promise is only redeemable while the visitor still holds a live session cookie (§6b). If visitor_sessions.expires_at lands before an approved_until it carries, the row promises a return that the credential cannot perform: the visitor comes back inside the window, presents a dead cookie, and has to knock anyway. Either the session's expiry slides on the same events, or it is provisioned long enough to cover the longest approved_until beneath it — otherwise the shorter deadline silently wins and the longer one is a lie told to the client. last_seen_at exists on the session for exactly this kind of extension.

Open, and deliberately not resolved here: what happens once a visitor's session ends. Whether a returning person lands in a grace period, keeps a recognisable identity, or starts over as a stranger is a product question this plan does not answer — and the answer changes how much either deadline actually matters. Addressed, not resolved.

The window itself has no reliable precedent (§5): Gather's ~12 hours is single-sourced and unverified, its 24-hour figure is legacy 1.0 documentation, and nobody else documents such a window at all. Pick it from expected usage. It is a product dial, not a security parameter: nothing about the design's safety depends on its value, because the invite can be revoked and the visitor kicked at any moment regardless.

Removal is manual, and comes in two tiers

Every product surveyed ships two, never one — a soft eject the person can recover from, and a sticky block needing an explicit undo. Zoom removes stickily by default; Meet's Remove does not block and a separate Block does; Gather lets any Member kick but restricts Ban to Admin/Moderator; Kumospace ships Remove and Block Guest as distinct actions.

  • KickVisitor — the removal action, and the only one. It does both halves and always both: it revokes re-entry by writing approved_until = now(), and it removes the LiveKit participant. Neither half is conditional on the other — kicking a visitor who has already closed their laptop is a legitimate and useful call, because the half that matters there is the re-entry block; LiveKit answers not-found and that is tolerated (§11, S5). Gated on its own KickVisitor permission (§7), which defaults to the same roles as AdmitVisitor — so out of the box "whoever may let someone in may put them out" still holds, Gather's split, while an organization that wants the two in different hands can now express it. Also requires presence in the office (§10).

    What a kick writes, which §5 left unspecified: it sets approved_until = now(), and it calls RemoveParticipant. Nothing else is written. The row then reads as expired through the same lazy computation every other terminal state uses, CreateVisitorOfficeToken refuses on the next mint, and §5's "nothing writes expired" stays true — no new column, no new status writer. The alternative, a kicked_at column, would add a second way to be dead for no gain: the visitor cannot distinguish the two, and §14 folds them into one terminal status deliberately. A kick does not refund the invite's use (§6).

  • RevokeVisitorInviteinvalidates the link, and only the link (settled 2026-08-17). It writes revoked_at, so no further knock can resolve the secret and any request still pending on it becomes unusable. It does not touch anyone already admitted: no RemoveParticipant, no disconnection, no fan-out.

The two actions are scoped to different things, and that is the whole model: revoke governs the gate, kick governs a person. Someone already admitted is past the gate — their admission was consumed at approval (§6) and their re-entry is checked against the approval, never against the invite — so closing the gate behind them is not a removal and should not pretend to be one. Removing them is KickVisitor, one person at a time, deliberately.

An earlier draft had revoke disconnect every live session born from the invite, synchronously, inside the request. That is dropped. It was never asked for; it made a link-management action fan out N LiveKit calls with no bound, no concurrency limit and no partial-failure story, so a single failed call would leave a visitor connected indefinitely (§12 evicts nobody on a timer) behind a response that said success. Revoke keeps its {} (§7) because it now has exactly one outcome.

What this costs, stated so review does not read it as an oversight. §18's "two tiers" claim changes shape: we no longer have a per-link mass removal, and the sticky tier is not link-shaped — it is KickVisitor, which is already sticky in the sense that matters (RemoveParticipant on LiveKit Cloud also revokes the participant's token, so they cannot rejoin with what they hold, and the mint fence refuses them afterwards). What we give up is the ability to eject ten people with one click. Given §6a's finding that a link-shaped sticky tier is weak anyway — the same person returns through another link — concentrating removal on the person rather than the link is the more honest of the two designs, not merely the cheaper one.

Note: how automatic removal would be built, if it is ever wanted

Kept because the analysis is done and the reasons it was not taken are product reasons, which can change. Nothing below is in scope.

LiveKit cannot do it for us. Verified against protocol@v1.41.0: CreateRoomRequest and RoomConfiguration carry only EmptyTimeout, DepartureTimeout and MaxParticipants — no max-duration, and nothing analogous to Daily's eject_at_token_exp / eject_after_elapsed or Twilio's MaxParticipantDuration (default 4 hours). Token expiry is a connect-time check on every platform checked — LiveKit, Agora and Vonage all document it — so shortening our TTL would make joining harder and evict nobody. Any automatic removal has to be ours.

The shape would be a visitorsweep module on the proven chatsweep pattern (ticker, Start(ctx), SweepInterval), selecting approved rows past their deadline, calling RemoveParticipant per row, then writing status = 'expired' — which would incidentally give that status its only writer.

The one trap worth writing down now, because it is not obvious: under horizontal scaling the sweep must not be a leader-elected ticker.

SELECT id, office_id, livekit_identity FROM private.visitor_join_requests
WHERE status = 'approved' AND approved_until <= now()
ORDER BY approved_until LIMIT 100 FOR UPDATE SKIP LOCKED;

Every replica ticks and claims disjoint rows. The requirement is per-row, not per-tick — advisory locks answer "only one replica runs the sweep", but the real constraint is "no two replicas evict the same visitor", which is what SKIP LOCKED answers, and it lets ticks overlap harmlessly. Session-level pg_advisory_lock is unusable regardless: PgBouncer marks it "Never" compatible with transaction pooling, and production runs the Supavisor transaction pooler on 6543. pg_cron cannot make the LiveKit call itself, though the house pattern of pg_cron → HTTP → a Go handler (reconcile-stale-recordings, migration 000019, secrets from Vault) is available and keeps credentials in Go.

And warn before cutting. Google Meet is the only product documenting a pre-warning — a chime and message at 50 minutes for its 60-minute cap, i.e. ten minutes' notice. Teams and Zoom publish no mechanics at all.


13. Metering and billing

Connected time must be attributable to a tenant. Do not put it on the visitor row: one approval yields N connection intervals, billing data must not depend on the lifecycle of an operational row, and it is metering, not visitor logic.

private.participant_activity_metrics already exists (migration 000010) and does per-user, per-org, per-day aggregation from the same webhook recorder. It cannot take a visitoruser_id BIGINT NOT NULL with a hard FK to private.users, and RecordSession resolves a WorkOS user id to it.

Add a sibling, keyed on the visitor request and scoped to both the tenant and the office:

private.visitor_activity_metrics
id BIGSERIAL PK
visitor_request_id BIGINT NULL REFERENCES private.visitor_join_requests(id) ON DELETE SET NULL
org_id BIGINT NOT NULL REFERENCES private.organizations(id) ON DELETE CASCADE -- the billing key
office_id BIGINT NULL REFERENCES private.offices(id) ON DELETE SET NULL -- which office
activity_date DATE NOT NULL
total_duration_seconds INTEGER NOT NULL DEFAULT 0
session_count INTEGER NOT NULL DEFAULT 0
UNIQUE (visitor_request_id, activity_date)

Same UPSERT-on-leave shape as its member counterpart, written from the same webhook recorder, owned by metrics rather than by this feature.

org_id and office_id are denormalised on purpose. A visitor request already names exactly one office through its invite, so both are functionally determined — but carrying them means the two rollups that matter, "minutes this tenant consumed" and "minutes this office consumed", are each a single index scan with no join. Index (org_id, activity_date) and (office_id, activity_date).

visitor_request_id and office_id are ON DELETE SET NULL; org_id is NOT NULL and the only durable key (revised 2026-08-17). An earlier draft made visitor_request_id ON DELETE RESTRICT, reasoning that a row carrying billable minutes should not lose its provenance because the operational row was tidied away, and that the database should refuse rather than the reaper remember to be careful.

The premise was right and the conclusion was wrong. Usage data is structurally independent of the operational row that produced it — but the way the industry expresses that is by letting the metric row be orphaned, never by making the operational object undeletable. RESTRICT inverts the dependency it was meant to protect: it hands the billing table a veto over routine product operations, so deleting an office would fail once any visitor had ever entered it. Since an office must stay deletable (§6), RESTRICT was also simply unimplementable here.

Migration 000033 settled the same question in this codebase, for the same reason, and its comment is the specification: "Recordings are billing/quota objects owned by the ORG, not the room's lifecycle … Deleting a room orphans the recording (room_id = NULL) but keeps org_id as the durable billing key, so consumed recording-minutes quota and audit history survive." This table is that table's sibling and takes its shape unchanged.

Externally the pattern is the same wherever it is documented. Orb states it outright — "Orb never overwrites or permanently deletes ingested usage data… Old events are marked as archived; they can still be queried via Orb's APIs but Orb will not use them for any billing functionality." Stripe's meter events have no delete verb at all, and a deleted Customer remains readable as a tombstone "in order to be able to track their history". Metronome offers archive and no delete; Lago's events API exposes only POST and GET. Microsoft is the most precise about the split that matters here: "the usage chart totals still include deleted users for the periods they were active, but they don't appear in the User Details table"the aggregate survives the deletion, the identifying detail does not. Note honestly that no standard or vendor documentation states this as a rule — it is inferred from how they build, not a citable requirement.

Consequence for the UNIQUE constraint, which must be in the migration comment or it will be rediscovered as a bug. UNIQUE (visitor_request_id, activity_date) stops binding once visitor_request_id is null, because Postgres treats nulls as distinct. That is acceptable — an orphaned row is no longer aggregatable per request, only per org and per office, which is exactly what it is still for — but the daily UPSERT must never encounter a null key, so it keys on live requests only and orphaned rows are terminal.

The chain one hop up now cascades, and that is safe. visitor_invites.office_id is ON DELETE CASCADE (§6) while visitor_join_requests.invite_id is ON DELETE SET NULL, so deleting an office removes its invites and nulls the pointers without ever reaching this table. The earlier fear — "one invite deletion reaches billing data two tables away" — is answered by the SET NULL in the middle rather than by a RESTRICT at the end.

Widening the existing participant_activity_metrics instead is worse on both counts: it touches a table a live 5-minute ticker reads, and Postgres treats NULLs as distinct in a UNIQUE constraint, so a nullable user_id would silently break the daily aggregation it exists for.

Flagged, not built now: the durable join time

Duration is computed from an in-memory mapr.participants[psid] holding JoinAt, written on join, read on leave. The LiveKit webhook is served by cmd/api. So at N replicas, join and leave land on different pods roughly (N−1)/N of the time, the leave finds nothing, and the session is never recorded at all. No error, no row. ParticipantsActive drifts the same way.

This is already true for members, before visitors exist, and it is a precondition of scaling the API past one replica — not hardening for later. room_presence.joined_at is written durably on join and would close most of it as a fallback when the map misses. Visitors get no room_presence row, so theirs would need the join time on their own row.

Neither table is invoice-grade until that is closed. Say it out loud before anyone bills from either.

On the business model

"N visitor-minutes per month then overage" is not an established pattern in this category. Miro, Notion, Atlassian and Kumospace all use hard caps or ratios (Atlassian 5:1 guest:paid); metered participant-minutes lives on the infrastructure side (Daily, Twilio, LiveKit — none of which distinguishes a guest from any other participant). Gather shipped the closest version — a monthly guest-hours pool — and reversed it in 2.0 (Sept 2025) in favour of per-member pricing, "you pay for your team, not your guests".

Not a reason to abandon the model, but a reason to understand why the one company that tried it retreated. What is not in doubt: whichever model wins, the unit is connected participant-minutes per tenant, so the measurement is worth building either way.

Where to refuse when a tenant is out of minutes: at approval, not at the mint. The member clicking Admit is the one who can act on it; refusing at mint approves someone and then bounces them.


14. Errors

New slugs in internal/apperr/slug.go:

  • visitor-invite-not-usable — unknown, revoked, expired or exhausted, one slug. plan.md §11 said a resolvable-but-dead secret "may say which"; that contradicts §12 and is dropped. The distinction has no product value on a pre-join screen, and one slug means one uniform failure with nothing to differential-time against.
  • visitor-join-request-not-found
  • approver-not-in-office — 403, distinct from the permission 403 on the same endpoint (§10).
  • visitor-invite-exhausted — 409, member-facing, raised when an approval would exceed the invite's max_uses (§6). Deliberately not folded into visitor-invite-not-usable: that slug is one uniform failure because the visitor's pre-join screen must give nothing to differential-time against, and none of that reasoning applies to an authenticated member who clicked Admit and is entitled to know exactly why it failed.
  • visitor-request-already-decided409, member-facing. Raised when the conditional approve or deny matches zero rows because someone else decided first (§6). This replaces the earlier "success-shaped already-decided body": losing the race is a conflict, and the frontend maps it to "somebody already handled this". Two conflicts can now come off the same button — this one and visitor-invite-exhausted — and they carry different copy, which is the point of separating them.
  • visitor-session-not-found — the session cookie resolves to nothing (expired, revoked, forged).
  • visitor-session-already-claimed409. Claiming is one-way and terminal (§6b).
  • visitor-not-admitted409, member-facing, raised by KickVisitor when the named request is pending or denied: that person was never admitted, so there is nothing to eject them from. Deliberately not folded into visitor-join-request-not-found — the row is right there in the panel the member is looking at, so "does not exist" would read as a bug. Every other state is a success: approved and connected is the normal case; approved and disconnected still writes the re-entry block (§12); already expired or already kicked is idempotent, because the desired end state already holds.
  • office-full — raised at approval (advisory) and at token mint (authoritative). Only the mint-time check is a guarantee; the office may fill between the two.

15. Security controls

  • Secrets: 256 bits from a CSPRNG, base64url, stored only as SHA-256, compared in constant time. NIST SP 800-63B requires a slow KDF only for memorized secrets; a look-up secret above 112 bits needs only an approved one-way hash.
  • The session secret is what prevents token theft. Without it, anyone learning a request id could read that request's state and mint its media token. Knowing an id is not holding the cookie that owns it — the server checks the request belongs to the calling session (§6).
  • Display names are never identifiers. Do not deduplicate, supersede or match on them — two people may both be "Alex", and keying on the name would let anyone cancel a stranger's pending request. Validated at write: length bound, permitted Unicode categories, no control characters, no bidirectional overrides, no zero-width. Validation is not the XSS mitigation; rendering is (§15a).
  • The doorbell invariant is enforced in the schema.
  • Uniform failure for unknown secrets, always hashing before branching so timing stays flat. Still OWASP's current recommendation.
  • Caps, in the order they matter: the secret is the gate; then uniform failure; then per-invite caps (concurrent pending, knocks per window) — the control that actually bounds a leaked link; then edge rate limiting on /visitor-access/*; then short TTLs. Note honestly that OWASP publishes nothing on per-credential caps or TTL as controls — API4/API6:2023 prescribe per-client limits, CAPTCHA and behavioural analysis, and the DoS cheat sheet keys only on IP. Per-link capping is our reasoning.
  • Per-IP belongs at the edge. X-Forwarded-For must never be trusted at face value; only the hop our own directly-connected proxy appends is meaningful, and even that is bypassable if the origin is directly reachable — its own infrastructure item.
  • Deliberately no CAPTCHA / Turnstile / Privacy Pass. OWASP API6:2023 lists them and IETF standardised Privacy Pass in June 2024 (RFCs 9576/9577/9578), but on a "type your name and knock" screen behind an unguessable link they are friction against a threat the link already gates. Revisit if a real griefer appears.
  • Denial cooldown keys on the cookie, plus the per-invite counter. Google Meet blocks a knocker after two rejections and has no more identity than we do — evadable by clearing cookies, exactly like Meet's, and a speed bump defeats the casual griefer.
  • Cache headers on every visitor response, copying user/api/getinfo.go: private, no-store, no-cache, must-revalidate, max-age=0, Pragma: no-cache, Expires: 0, Vary: Cookie. no-store is load-bearing — no-cache still permits storage with revalidation, and without it an intermediary can serve a cached pending forever.
  • Never put the LiveKit token in a URL or a cookie — it is consumed by LiveKit Cloud, a different origin, so a cookie on our domain could not reach it anyway. Body only, in memory, 10-minute TTL. The asymmetry is the point: an XSS can steal a 10-minute media token; it cannot steal the long-lived capability cookie, which is HttpOnly.
  • Capacity is checked at token mint, not only at approval.
  • Cost: visitors consume LiveKit minutes and the plan's concurrent-participant ceiling is the real cap. Note that lk_participants_connected will not see them (§13).
  • Revoking an invite reaches pending requests only — never a live session, and never an already-approved one. Removing an admitted visitor is KickVisitor, per person (§12).

15a. Untrusted-string containment

Two strings authored by an unauthenticated stranger render inside authenticated members' UI: the display name, and later any in-room text. BigBlueButton shipped a stored XSS in exactly this place — CVE-2023-43797, CVSS 6.3, guest lobby messages through unsafe innerHTML, fixed in 2.6.11 / 2.7.0-beta.3.

The display name passes through our API and is validated there. Anything on the LiveKit data channel does not — payloads cannot be inspected server-side — so its safety is entirely rendering discipline, enforced mechanically:

  • Lint rules forbidding raw-HTML injection, including through wrapper components, failing CI.
  • No raw-HTML plugin in any markdown path on the visitor surface.
  • Any sanitizer at its default allow-list; widening it can cause a full bypass.
  • The desktop application's baseline verified rather than assumed — context isolation on, node integration off, sandbox on. An XSS in a desktop renderer is strictly worse than in a browser tab.

15b. A pre-existing flaw this feature does not introduce and does not fix

Each client computes who may subscribe to its own published tracks from positions its peers broadcast over the LiveKit data channel, which cannot be validated server-side. A client that lies about its position induces honest clients to grant it microphone and screen-share subscription. This is true today for members and is not created by visitor access — but visitor access widens exploitation from "anyone with an account" to "anyone with a link and an approved knock".

It is also the decisive argument for keeping the waiting visitor outside LiveKit. A participant inside the room is on that data channel even with a restricted role, so an unadmitted waiter could broadcast a position and induce honest clients to send them media before approval. Under an in-room lobby, admission would stop being a media boundary at all. 100ms ships that model safely because it has no spatial, position-driven subscription; we cannot.

Accepted position: ship with this open, because production has no customers. Server-authoritative movement is a precondition for opening to pilot customers, in the canonical form — the client predicts and moves immediately, the server reconciles and can revoke. Two facts for whoever picks it up: there is no server-side API for per-publisher subscription permissions (SubscriptionPermission lives in the participant-to-server signalling protocol), and once track-level permissions are in use, newly published tracks are not automatically covered. Tracked as its own security issue.

15c. No cap on invite count — an observed ratio instead (2026-08-07)

Decided: there is no per-plan limit on how many invites an organization may hold. §6a raises the need for a bound; this is the answer, and it is a dashboard rather than a number.

Nobody caps invite-like objects per plan. Slack publishes no numeric cap on invitations or invite links; Figma and Miro publish none (both gate guest capability by plan, not guest count); Notion caps guests at 10 on Free only and documents unlimited guests on every paid plan, having removed its earlier paid-plan caps; Atlassian uses a 5:1 guest-to-paid-user ratio rather than a count. The one hard plan-tiered numeric cap found in the whole survey is Zoom's concurrent meetings — 1 on Basic/Pro, 2 on Business+, with paid add-on licences for 4 or 20 — and that governs an infrastructure-cost resource. An invite row costs nothing. Visitor minutes cost real money, and §13 already meters them. Capping link count caps the wrong resource.

Slack is the closest analogue and its control is behavioural, not numeric: "Slack may limit your ability to send invitations… if you've sent many invites but few have been accepted." A recruiter sending ten links a week that get used is never throttled on any plan; a compromised account minting thousands nobody uses is throttled immediately. The signal separates legitimate volume from abuse without knowing anything about the plan — which is why no plan dial is needed.

A per-plan number would have been ours to store anyway. Stripe's ActiveEntitlement object has exactly five fields — id, object, feature, livemode, lookup_key — and no quantity; the Feature object is pure metadata. Stripe's entitlements guide frames the whole system as "grant or revoke access", binary by design, and puts numbers in a separate object model (subscription item quantity, metered pricing) it never wires to entitlements. WorkOS Entitlements is a Stripe-backed add-on and inherits that shape. WorkOS Organization metadata is capped at 10 string-only key/value pairs, is not filterable, and WorkOS's own docs tell integrators needing more to keep the data in their own database. So: entitlements answer which tier, never how many.

So the control is not in the application. Nothing in this feature enforces a ceiling on invite count — no column, no config, no plan lookup, no throttle. Abnormal volume is something we observe and act on commercially, not something the backend refuses. That is the whole decision; everything below is context for whoever builds the observing.

Nothing of this exists yet — it is a hypothesis, not a design. There is no dashboard, no queries, and no metric. Recorded here so the absence of an in-app cap is understood as a choice with somewhere else to look, rather than an oversight to be "fixed" by a later reviewer adding a limit.

Two candidate signals, if and when someone builds it. They detect different failures, so collapsing them into one number would produce something meaningless:

SignalReadsWould detect
Invite-level — live invites never used ÷ invites createdlinks minted that nobody walks throughinvite spam, compromised member account
Request-level — knocks approved ÷ knocks total, over approval-required invites onlypeople arrive but nobody lets them inan unstaffed office, or knock spam against a leaked link

The second signal must exclude auto_approved admissions or it is meaningless: a no-approval invite is 100% approved by construction, so mixing the two dilutes the ratio toward 1 and hides exactly the unanswered knocks it exists to surface.

The same two axes read commercially as well as defensively: high volume with high acceptance is a customer outgrowing their plan; high volume with low acceptance is abuse.

Whatever gets built is an internal dashboard, not a customer panel. panels.go states the convention outright: "Internal dashboards (manually configured in Grafana Cloud) and customer panels (defined here) are two separate systems that happen to read from the same Mimir instance." This must not go into panelRegistry — that registry is the tenant's own analytics view, and an acceptance ratio is our commercial and security signal, not theirs.

Aggregates only, never identities. Counts per org and nothing else. Visitor display names are unverified stranger-authored text (§15) and personal data; they must never reach our internal observability.

The data needs no new bookkeeping. visitor_invites and visitor_join_requests already carry use_count, revoked_at, expires_at, status and org_id, so whoever builds this is writing queries, not instrumentation — no counters to maintain, consistent with §5's compute-at-read discipline.

Honest limitation: observation is detective, not preventive. Slack's throttle enforces automatically; a dashboard needs a human to look at it, and until someone does, invite creation is unbounded. Accepted for a specific reason rather than by omission: §1 — the invite is a doorbell, so unbounded invites are not unbounded access. Every entry still costs a live approval by a member inside the office, and both removal tiers (§12) work regardless. What is genuinely unbounded is row growth and knock volume, and the latter is already bounded by §15's per-invite caps and edge rate limiting on /visitor-access/*.

And it is the right order. You cannot tune a fuse you have never measured. If the observed distribution ever shows a ceiling worth enforcing, a throttle can be added then, with a number taken from data rather than guessed — which is the order Slack's own control implies, since a behavioural threshold exists only because someone measured the behaviour first.

15d. Structured so retention and obliteration are cheap to add later (2026-08-17)

How long to keep anything is deliberately not decided here. What is decided is that the schema must make both answers cheap to introduce, because retrofitting them onto populated tables is a migration and a backfill. Three structural properties carry that, and all three are free today.

Research memo: .scratch/research/resource-deletion/findings.md — ten sources, and the three that bind are ISO 27002:2022 8.10, which asks for defined retention periods rather than indefinite holding; the Hamburg DPA's €900,000 fine for retaining personal data past its deletion term even though it was never shared with anyone, which establishes that keeping data is itself the violation; and the fact that every metering and analytics vendor surveyed bounds usage data explicitly — Twilio at 7 days, Daily at 1–21 by plan, Google at 6 months, Zoom at 15 months. Nobody keeps it forever. We are not choosing a number yet; we are refusing to make choosing one expensive.

1. Every table answers "who owns this?" alone. org_id NOT NULL on all four, never derived (§6). Both "delete everything belonging to org X" and "minutes org X consumed" stay a single indexed predicate per table, valid even on rows whose every other pointer has been nulled.

2. Every table answers "when did I become inert?" without a join. A retention sweep selects rows whose life ended before some instant, so each table needs a local anchor: visitor_activity_metrics has activity_date, visitor_invites has expires_at / revoked_at, visitor_join_requests has expires_at / approved_until, visitor_sessions has expires_at. These exist but are heterogeneous and implicit — each migration must name its retention anchor in a comment, so that building the sweep is one query per table rather than an archaeology exercise.

3. Identifying columns are segregated from aggregate columns. All personal data in this design is display_name and livekit_identity on visitor_join_requests, and secret_hash on visitor_sessions. visitor_activity_metrics carries none — deliberately, and it is the property Microsoft's split depends on (§13): obliterating a visitor is a targeted UPDATE over two tables and the billed minutes are never touched. This is fragile in one specific way, so state it in the migration: adding a display name to the metrics table "for the dashboard" destroys it.

Together these give the shape the regulators describe without any of the machinery yet — the live row is really deleted, what survives is stripped of identifiers, and the surviving rows carry both a tenant and a clock. GDPR Art. 18 restriction, CNIL's archivage intermédiaire and the archive-table pattern from the engineering literature are three names for that same architecture. A deleted_at column is none of it — it moves nothing out of operational use, reduces nothing to the indispensable, and restricts no access — which is why offices stay hard-deleted (§6) and why this plan adds no soft-delete column anywhere.

(Honest limit: no regulator or EDPB source states whether an application-level soft-delete flag satisfies Art. 17. Our position is a convergent inference from three independent sources, not a citable rule. Recorded so a future reader does not over-claim it.)


16. Explicitly out of scope

Visitors get audio, video, screen share, and nothing that needs a private.users row.

  • Visitor chat through our API, presence, analytics — all require a user row: chat_messages.sender_id and chat_conversation_members.user_id are hard FKs, so admitting visitors would mean polluting that table or making the sender polymorphic on the busiest table in the product.

  • The organization member directory.

  • In-room text chat — wanted, and the route is the LiveKit data channel, not our chat tables. Entirely frontend work, public to the whole office by construction, so it must be named office chat, never zone or private chat. Inherits §15a in full.

  • Multi-office invites — see §6, and note the reason changed when sessions arrived: it is no longer "a visitor has no durable principal" (§6b gives them one) but a product choice. An invite names one office; a person accumulates offices by being admitted to each. A member wanting one link that opens five offices is describing a temporary collaborator who should take a seat, not a visitor.

    (The visitor dashboard used to sit on this same line and no longer does — it is in scope as of 2026-08-07. The two were never the same question: multi-office invites are refused because the design has no durable principal to hang an allowlist on, whereas listing and managing the outsiders who can reach an org's offices needs no such thing. §18 records that no surveyed product ships an invite inventory at all, which makes it a differentiator rather than table stakes.)

  • Email or push notification, any WorkOS user creation, a ban list (ineffective against an unidentified principal). (Centrifugo access for the visitor was previously listed here and is no longer out of scope — §9. What remains forbidden is a visitor on any channel but their own, and any visitor token carrying a real meta.org.)

  • Repairing the organization invite link's plaintext token storage. Pre-existing debt — and explicitly not a model to copy. organizations.invite_id is a 128-bit token (GenerateSecureToken(), 16 bytes) stored in clear, serving as identifier and secret at once, with the org name in the URL path ({baseURL}/join/{orgName}/{inviteId}). Visitor invites keep the two values apart — public_id is displayable, secret_hash is never displayed — because ListVisitorInvites must show every invite in a panel while the secret must never leave the server after creation. One value cannot be both a listable identifier and an unguessable capability. If the helper is reused, it must be GenerateSecureTokenWithLength(32) for §15's 256 bits, not the 128-bit default.

  • Affirmative recording consent. Revisit before pilots, alongside the movement work.

  • The registered-guest tier (permissions.RoleGuest). A separate feature, and currently unreachable: sendinvites.go:96 passes an empty role slug, admitguest.go is commented out end to end, and Role.IsAssignable() returns false for guest. Its allowlist would hang on the membership (N offices, admin-editable), not on a capability — a different table with a different lifecycle. What the two share is the predicate, not the storage.


17. Build order

Each step is independently reviewable and leaves the tree green. The security spine cannot be deferred, because retrofitting it is a rewrite: the capability middleware, the typed VisitorSession as its own type, hashed secrets, the attribution CHECKs, CanUpdateOwnMetadata: false, no WorkOS user, a visitor token that reaches exactly one channel, and the media mint on its own endpoint. All of it is in the POC.

Milestone 0 — POC: a stranger gets in. Deliberately absent: arrival mode, max_uses, edge limits, reapers, containment lint.

The visitor's realtime channel is in the POC; the office channel is not. They separate cleanly: without the visitor channel the flow does not work as designed (the visitor would have to poll, which is the fallback, not the product), while without the office channel members simply re-read the pending list on entry and every 5s — a slower path to the same screen, and no rework when S7 lands.

#SubtaskDepends on
S0roomsoffices rename, DB and internal/repository only. Table and columns renamed, rooms.room_idoffices.office_id, sqlc regenerated, legacy method names swept. recordings.room_id moves from UUID → rooms(room_id) to BIGINT → offices(id) — a type change with backfill, preserving 000033's nullable + ON DELETE SET NULL, not just a rename. Recording's frozen wire DTOs are not touched; the repository translates at the boundary. Pure refactor, no behaviour change, deployable alone. Must land before S1, or the visitor migration writes rooms FKs and is rewritten immediately after — two migrations on three newborn tables instead of one
S1Persistence: all three tables (§6); the revised attribution CHECKs anchored on decided_at (§6) and ON DELETE SET NULL on every user, session, invite and office FK — no RESTRICT anywhere, with org_id NOT NULL … CASCADE as the only upward pointer; each migration naming its retention anchor (§15d); conditional increments for use_count and token_mint_count; testcontainer tests, including one asserting an approved row with neither decided_at nor auto_approved is rejected, and one asserting an office with live visitor rows can still be deletedS0
S2Session middleware: the visitorSession scheme in SecuritySchemes() (cookie name from the manager's constant); RegisterWithVisitorSession and RegisterWithVisitorClaim — the latter owning the AND-form op.Security so §8's OR trap cannot be written by hand; VisitorSession type + visitorSessionKey + accessors, sharing nothing with AccessTokenClaims; the resolver interface; logger.VisitorSessionID and logger.VisitorRequestID. No CORS change — credentials are already allowed and the X-Visitor-Secret header is dropped
S4aOffice-entitlement seam: CreateOfficeToken takes one resolved entitlement, not five identity strings. Droppable under pressure, knowing the cost is a second fence in one service body — and a third when the registered tier arrives
S5Platform: publishable-sources grant field; sever RemoveParticipant's not-found (§11)
S3Member flows: create/revoke/list invites, list/approve/deny requests; the three catalogue entries AdmitVisitor, ManageVisitorInvites, KickVisitor with the §7 role defaults (all three false for RoleGuest) and their EditablePermissionsPolicy entries; the presence check (§10), which now covers kick as well as approve/denyS1
S2bThe visitor realtime credential: IssueVisitorConnectionToken beside the member issuer — sub = the session identity (so user_connection_limit bounds per browser, §9), no meta.org, one channel in the channels claim, TTL covering the pending window. The visitor: namespace in ChannelCatalog() as SubscriptionToken + Recovery: false, config regenerated. No subscribe-proxy branch — server-side subscription never reaches it
S4Visitor flows: preview (pure read), knock (row + cookie + realtime token + office publish, one transaction), GetVisitorSession (pure read, triples as baseline/fallback/re-issue), media token. Grant, seat check, never CreateRoomS1, S2, S2b, S4a, S5
S4bRemoval: KickVisitor — own permission, presence-checked (§7, §10), writes approved_until = now() and calls RemoveParticipant synchronously, tolerating not-found; the visitor-not-admitted 409 (§14). RevokeVisitorInvite removes nobody and stays in S3 as link management. No sweep, no job, no fan-out. Must ship in the same release as S4 — see belowS3, S4
S6Errors, feature module, route registration, apitags.VisitorAccess, the visitor.request_resolved declaration, contract regen, frontend handoffS3, S4

Entry and removal are one shippable unit (2026-08-17). S4 and S4b are separate subtasks for review, never separate releases. The build order above is ordered by reviewability, and read as a deploy sequence it would put a window on main where a stranger can enter an office and nobody can put them out. Under trunk-based development that is a broken release even though it compiles. So either the two land in one task, or S4 registers its routes last — and the first is the honest shape, because the second writes removal code for something that does not yet exist.

Two prerequisites of removal that are easy to mistake for entry-only work: S5's severing of RemoveParticipant's not-found into apperr.ErrNotFound (without it, removing an already-disconnected visitor errors and logs at ERROR — the common case, not the edge one), and the presence check of §10, which now covers kick.

Both of the removal questions this section previously left open are now settled (2026-08-17): RevokeVisitorInvite removes nobody, so the unbounded fan-out and its partial-failure story simply cease to exist (§12); and the unkickable-request case gets its own slug, visitor-not-admitted, with every other state treated as success (§14).

Done for S2b: a visitor token subscribes to its own channel and nothing else — an attempt on status:/typing: is denied by the existing proxy because meta.org is empty (assert it, it is the property the whole design rests on); a second token for the same session does not multiply connections past user_connection_limit; the token outlives the pending window; the office channel's config is untouched.

Milestone 1 — v1. S7 the office: namespace and its two events · S8 arrival mode (presentation, not enforcement — say so in the code) · S9 caps and counters · S10 untrusted-string containment (HITL, two repos) · S11 the e2e journey and its visitor fixture · S11b visitor activity metering — the only cost control this design has, now that nothing evicts on a timer, so it earns its place in v1 rather than hardening.

Milestone 2 — hardening. S12 edge posture · S13 row cleanup and the presence reaper · S14 platform validation gates (two visitors from one invite coexist; concurrent approve; attribute rewrite refused; knock at a roomless office creates nothing; publisher-set subscription permissions hold, including for tracks published after) · S15 recording consent.

Spun out. The position-spoofing flaw (§15b) — precondition for pilots, not visitor scope. And the durable join time (§13) — precondition for scaling the API past one replica, and already true for members.

Unit tests accompany every Go file. The capability middleware and the approval transition earn adversarial tests specifically: a session cookie used against another session's request, a revoked invite mid-flight, concurrent approvals asserting exactly one decision survives, and a text/plain body rejected before any handler runs.


18. Where we deliberately differ from the industry

Recorded so review does not mistake a choice for a convention.

What everyone else doesWhat we do, and why
Waiting participantSplit — Daily and Chime hold them outside, 100ms holds them inside with a restricted role, LiveKit and Agora take no positionOutside. Cost (a connected waiter burns participant-minutes) and §15b (an in-room waiter is on the data channel before approval)
Re-entry windowNobody documents one reliably. Gather's ~12h is single-sourced and unverified; its 24h figure is legacy 1.0 docs; no other product documents such a windowOurs to choose from expected usage, with Gather's 12h as a weak sanity check only. Not a precedent we can claim to be following
Time-based evictionNo product does it. Expulsion is universally a human action by the inviting orgSame — we do not either. A connected visitor stays; the org bears the time and removes by hand. §12 keeps the note on how to build it if that ever changes
Metered visitor minutesHard caps and ratios dominate; Gather tried a pool and reversed it in 2.0Undecided. Build the measurement regardless — every model needs it
Notifying the waiterSplit — BBB polls; Jitsi, Daily and Whereby push over a connection the client already holds. Verified empirically: Gather's unadmitted visitor holds an open websocket, and Teams' lobby participants are signalling-level RemoteParticipant objectsPush, on a per-browser channel (§9). Following the two products we could actually measure
Poll pacing in the body (fallback path only)Azure mandates Retry-After; GitHub ships X-Poll-IntervalBody field. Forced by the house rule that every operation answers 200
Kick tiersUniversally two — soft eject and sticky blockOne, person-shaped. KickVisitor both disconnects and revokes re-entry (LiveKit Cloud revokes the participant's token on removal, and the mint fence refuses afterwards), so the soft/sticky distinction collapses into it. RevokeVisitorInvite is not the second tier — it manages the link and removes nobody (§12). We ship no mass ejection, and §6a's finding that a link-shaped sticky tier is weak anyway is why that is acceptable
Who owns an inviteNobody documents what happens to a link when its creator is offboarded — Slack, Notion, Figma, Miro and Atlassian are all silent. Every product that chose user-ownership needed a transfer bolt-on, and each is bounded by a deadline, a scope limit or a trigger it does not fire on (Figma: no reassignment at all, files orphan; Google: 20 days then deleted; Notion: 30 days, shared content excluded; Zoom: not PMI meetings; Teams: not the join link). Entra ID chose org-ownership and needed no transfer — the guest's access survives and an admin reassigns the Sponsors attribution during offboardingOrg-owned, creator-attributed, revocation gated on ManageVisitorInvites. The invite keeps working when its creator leaves; expiry and the use cap end it, never a colleague's departure. No framework requires otherwise: SOC 2 CC6.2/6.3, ISO 27002 5.16/5.18/5.19, NIST AC-2/PS-4/PS-5 and CIS 6.1/6.2 all key deprovisioning to the access holder, never the granter. What they do require is that the granter be recorded (NIST AU-3: "identity of any individuals … associated with the event") and the access periodically reviewed — which decided_by_user_id, expires_at and max_uses already satisfy. "The granter left, the grant remains" has no name in any vendor glossary or control catalogue
Sticky tier's shapePerson-shaped — Kumospace ships Block Guest with a "Manage Blocked Guests" unblock list; Gather restricts Ban to Admin/ModeratorLink-shaped, forced by §16: a ban list is meaningless against an unidentified principal. Weaker the more links live per office (§6a) — mitigated because re-entry costs a fresh human approval
Invite inventoryNo product lists invite links as manageable objects. Gather and Kumospace both manage people only — Gather's "Manage Guest Access" shows guests as Active/Expired/Revoked with extend/revoke, and no link-list UI exists anywhere in its documentation; Kumospace's People menu lists Members/Guests in-space. Neither shows which link a guest arrived through, or who created itBoth lists. ListVisitorInvites for links, and a visitor list for people — with invite_id on the request row making "which link, created by whom" a join we already support. Google's File Exposure Report has no grantor column at all, Atlassian's Actor column needs paid Guard, and Entra's Sponsors is a per-profile field never a list column: shipping this in a list puts us ahead of every product surveyed

19. What the frontend needs to know

  • Do not store any secret anywhere. The server sets the session secret as an HttpOnly cookie scoped to /visitor-access; the browser attaches it. There is no per-request secret. On returning to the page, call GetVisitorSession with an empty body and resume.
  • On first load, read the invite secret from the URL, remove it with history.replaceState(), and send it in the body of preview and request — it is never a cookie and never a header, so two invite links open in two tabs cannot overwrite each other (§8). Set Referrer-Policy: no-referrer on the join page.
  • Branch on requiresApproval, which preview tells you before the name field. The two paths are genuinely different, and conflating them is the single most likely integration mistake here:
    • Approval required — the knock returns pending with realtimeToken and connectURL.
    • No approval — the knock returns approved and neither field. There is nothing to wait for. Go straight to the token call. A client that opens a socket unconditionally will hang waiting for an event that already happened.
  • When there is a socket, open it with the token and you are already subscribed — the subscription rides the token, so do not call subscribe() for a channel yourself. The channel is per browser, not per request (§9), so one socket serves every office you are waiting at, and each event names the request it resolves.
  • Immediately after the socket is up, call GetVisitorSession once. Not optional and not a poll: it is the baseline that closes the race between the knock returning and the socket connecting. A member approving in that gap publishes into a channel you are not yet listening on, and the namespace has no recovery.
  • Then wait for the push. On approved, call CreateVisitorOfficeToken with that requestId once, and again on any cold re-entry.
  • Fallback, only if the socket cannot be established (corporate proxy, hostile network): poll GetVisitorSession every 2s for the first 30s, then 5s, obeying nextPollInSeconds. One loop covers every office you are waiting at — do not run one per request. Stop after ~5 minutes and show "nobody has answered yet" with a control that resumes; the request stays admissible for ~15. Use each access's expiresAt to render a countdown and to stop on your own if the server becomes unreachable. This is the exception path; do not build the happy path on it.
  • On reopening the page, call GetVisitorSession: it returns every access this browser holds and a fresh realtimeToken, so you reconnect without re-knocking. Nothing needs to be kept client-side.
  • Signing in does not merge anything by itself. To attach a real identity to this browser's visitor history, POST ClaimVisitorSession while holding both cookies. It is one-way and retroactive: past admissions become attributed too, the visitor keeps every access they had, and the server rotates the session cookie in the response — let the browser take the new one.
  • Subscribe to office:{orgId}:{officeId} on entering an office, and treat subscribing as the trigger to fetch the pending list. Unsubscribe on leaving. Recovery is off by design.
  • Show the typed name as unverified. Disambiguate duplicates by request time, never by name.
  • Render every visitor-authored string as text, never markup — the lint rules of §15a are the enforcement, not intention.
  • Read the arrival-mode attribute and disable the view switch for a conference-only visitor. This is presentation, not enforcement. Do not let a disabled button be mistaken in review for a security control.
  • A visitor is never disconnected on a timer, and never because a link was revoked. The one thing that ejects them is a member calling KickVisitor — handle that as an immediate disconnect with an explanation, not as an error. A visitor still waiting (pending) can be cut off by revocation: their access simply reads expired. approved_until only affects whether re-entry needs a new knock.
  • OfficeMetadata.GuestState is not used by this design. Confirm whether anything on your side still reads it.

20. Compliance — one deferred, one open, one revision (2026-08-17)

From an online-research pass against §6. Three items: a consent obligation we are choosing to address later, a retention question that stays open, and one place where §6 was wrong and is corrected. This is research, not legal advice; where a claim rests on a secondary reading it is said so.

The cookie is persistent by construction. §8 derives MaxAge from pendingWindow + approvedWindow, and §12 pushes visitor_sessions.expires_at further out still so it covers the longest approved_until beneath it.

WP29 Opinion 04/2012 (WP194) §3.2 excludes exactly this by name: "persistent login cookies which store an authentication token across browser sessions are not exempted under CRITERION B", with footnote 4 reconfirming it. Only session-duration authentication cookies are exempt. Three ways out were checked and all three close:

  • An explicit user action does not restore the exemption. WP194 treats a "remember me" control as the mechanism for obtaining consent, replacing the exemption rather than recovering it. The knock being deliberate does not help.
  • None of §8's engineering bears on it. HttpOnly, Secure, SameSite, a 256-bit opaque value, server-side hashing — WP194 §5 makes the purpose the basis "rather than a technical feature of the cookie", and EDPB Guidelines 2/2023 (v2.0, adopted 7 Oct 2024) confirm Art. 5(3) bites on any information stored on terminal equipment regardless of whether it is personal data, citing Planet49 (C-673/17, §70).
  • No DPA exempts a persistent functional cookie for later return. WP194 §3.6 tolerates session or a few hours where the user requested the persistence; nothing found extends that, and CNIL's 13 months is consent validity, not an exemption threshold.

So the pending window alone is comfortably exempt, and the sliding re-entry window is what triggers consent — the convenience feature, not the admission. A "by knocking you accept" banner would fail Planet49's active-unambiguous-behaviour standard.

Deferred deliberately, and cheap to defer because nothing in §6 moves. It is a knock-screen change. Two shapes for whoever picks it up: an explicit consent control at the knock whose refusal still admits the visitor (session-only cookie, re-knock on return), or capping the cookie at session duration outright and dropping frictionless re-entry. Nothing else in this plan depends on which.

20b. Retention — open, and today's mechanism governs access rather than data

Current handling, stated plainly so it is not mistaken for an answer: there is no manual removal of a visitor's data, and when a session's period ends the visitor must knock again. That ends the credential, not the rows. visitor_sessions.expires_at withdraws the ability to return; it deletes nothing, and every join request, invite and metric row persists indefinitely. Retention is therefore genuinely undecided, exactly as §15d says, and §15d's structural work stands unchanged as the preparation for deciding it.

What the research adds is that the absence of a defined period is itself the finding regulators make, independently of whether anything was over-retained. Deutsche Wohnen is the case: the Berlin DPA's €14.385M was for storing tenant data with no capability to delete what was no longer needed (Art. 5(1)(c)/(e), Art. 25(1)); the CJEU in C-807/21 (5 Dec 2023) only addressed the conditions for fining a legal person and left that holding untouched; on remand LG Berlin I (10 Jun 2026) upheld the missing Löschkonzept and cut the fine to €900,000, crediting the introduction-phase context and remediation. (Unrelated to the Hamburg €900,000 debt-collection fine already cited in §15d — same figure, different case. Worth the parenthesis, because the coincidence invites a wrong merge.) CNIL enforces the same axis, with absence de définition d'une durée de conservation and absence de mécanisme de purge as recurring grounds (SAN-2024-002, PAP, €100k, Art. 5(1)(e)). Art. 30(1)(f) wants the envisaged erasure limits in the record of processing, and EDPB Guidelines 4/2019 put deletion in the Art. 25 by default obligation.

Position: a number per table before the first real visitor, not before the first commit. §15b already records that production has no customers, so the exposure is prospective — but it is a discussion on the clock, not a backlog item.

20c. Erasing a visitor is a scrub, not a nulled FK — revises §6

§6 conflates two data subjects, and its terminal shape only answers for one of them.

  • The erased person is a member (creator or decider): SET NULL is right and §6's 2026-08-17 reversal holds unchanged. The Austrian DSB accepted anonymisation as erasure on a disproportionate effort standard, and held the data subject cannot demand a particular method (DSB-D123.270/0009-DSB/2018, 5 Dec 2018).
  • The erased person is the visitor: nulling session_id, invite_id and office_id erases nothing about them. display_name is free text they typed and livekit_identity is their handle; both survive every null §6 describes, and the row remains singled out and linkable. WP216's three prongs (singling out / linkability / inference) reach it; EDPB Guidelines 01/2025 on pseudonymisation keep it personal data for whoever holds the additional information; and CJEU C-413/23 P (EDPS v SRB, 4 Sep 2025) both makes the status relative to the holder — we hold the rest of the database — and holds that free text authored by a person relates to that person even when pseudonymised.

So obliterating a visitor is an UPDATE that empties, and the request row itself never dies. This confirms §15d's instinct and makes it exact:

UPDATE private.visitor_join_requests
SET display_name = NULL, livekit_identity = NULL, session_id = NULL
WHERE session_id = $1;
DELETE FROM private.visitor_sessions WHERE id = $1;

Four consequences, and all four belong in the migration rather than in someone's memory:

  1. display_name and livekit_identity must be NULLABLE. §6 declares them as bare VARCHARs. A scrub cannot run against NOT NULL, and widening the column later is a migration on a populated table — free today, expensive at the exact moment it is needed. The read-path consequence §6 already states for a missing creator or decider now extends to the visitor themselves: every DTO must render "a visitor, since removed" rather than an empty cell.
  2. session_id must be nulled in the same statement, and it is the one that will be forgotten. Emptying the name while the session pointer survives leaves every admission of that person still joined to every other across tenants — the precise linkage §6b spends its length preventing. Deleting the session row achieves it through the existing ON DELETE SET NULL; nulling it explicitly as well is belt-and-braces and, more usefully, self-documenting.
  3. §13's UNIQUE wrinkle does not arise on this path — a point in favour of the scrub that was not visible when §13 was written. §13 warns that UNIQUE (visitor_request_id, activity_date) stops binding once the FK is null. Under scrub-in-place the request row is emptied and never deleted, so the FK stays valid, the constraint keeps binding and the daily UPSERT never meets a null key. ON DELETE SET NULL stays as the fallback for the genuine-delete paths (the org cascade), where it costs nothing. The metrics survive attached to a live but anonymous row, which is the shape §13 wanted and reached by a worse route.
  4. What remains is defensible as non-personal, and the argument must be written down rather than asserted. After the scrub the row carries org_id, office_id, status, timestamps and surrogate ids: still a distinguishable record, but nothing left attributes it to a person and we hold no additional information that would — which is exactly C-413/23 P's relative test. State the claim as no longer attributable by means reasonably likely, never as "anonymous, full stop": the EDPB's Coordinated Enforcement Framework report on the right to erasure (adopted 18 Feb 2026, 32 supervisory authorities, 764 controllers) names anonymisation substituted for deletion "without sufficient guarantees for its irreversible nature" among its recurring failures. The failure mode is asserting irreversibility, not choosing anonymisation.

Two things the scrub does not reach, stated so nobody claims more for it than it delivers:

  • visitor_invites.label is member-supplied free text the visitor never sees (§6a), and in practice it will contain their name. That is personal data about the visitor sitting outside every table this UPDATE touches, with no key leading from a session to it.
  • livekit_identity has already left the building. §6 records that it travels into room metadata, webhooks and recordings. Nulling our column reaches none of them, and Art. 17(2) makes informing those recipients a separate obligation. §15d's "a targeted UPDATE over two tables" is true of our database, and only of our database.

And the missing piece is the trigger, not the statement. A visitor holds a cookie and nothing else; once visitor_sessions.expires_at passes they cannot identify their own rows, and §12 leaves what happens after that expressly unresolved. There is no endpoint and no route by which a visitor could ask. Whether Art. 11 (processing not requiring identification) relieves a controller who genuinely can no longer link a residual row is open — nothing found either way.

20d. Who the controller is — unstated anywhere in this plan, and it decides who owes 20a and 20c

visitor_sessions exists for a purpose no tenant instructed: it crosses their boundary by construction, and no single organization could have asked for it. EDPB Guidelines 07/2020 make the purpose — the why — the test rather than access or storage, and Art. 28(10) turns processing for one's own purpose into controllership for that processing (Fashion ID, C-40/17: assessed per processing stage). EDPB Guidelines 2/2023 §3.5 describes an identifier "collected and shared amongst several controllers to uniquely identify a person over different datasets" as squarely in Art. 5(3) scope, while deliberately declining to say which exemption applies to it.

The practical reading is that we are very likely controller for visitor_sessions and for the claim, whatever our role for the rest — so the Art. 13 notice on the knock screen and the Art. 15/17 handling of 20c fall to us and not to the tenant. Flagged, not resolved: this plan names no role anywhere, and both 20a and 20c hang off the answer.

What this pass did not disturb

Three §6 choices came through the research stronger than they went in, and should not be re-litigated by a later reviewer: org_id NOT NULL on all four tables (tenant obliteration in one indexed predicate per table, valid even on fully orphaned rows); livekit_identity per request rather than per session, which defeats WP216's linkability prong across tenants and is the best compliance argument in this document — Daily.co's HIPAA domains scrub to a random session_id for the same reason; and no soft-delete anywhere, where §15d's own disclaimer remains accurate, nothing having been found on deleted_at versus Art. 17 in either research round.

Limits of this pass. WP194 and EDPB Guidelines 2/2023 were read in primary. EDPB Guidelines 01/2025 and the CEF 2026 erasure report resisted text extraction and are triangulated from secondary reporting. The cross-tenant session of §6b has no authority on either side — 20d is an inference from general principles, and is the item most deserving of qualified counsel rather than more searching.