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 mode —
officeorconference, 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
| Decision | Choice |
|---|---|
| Visitor identity | No WorkOS user, no private.users row, no RBAC role, no principal in the request pipeline |
| Where the visitor's secret lives | Invite 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 visitor | Outside LiveKit entirely — the pre-join screen, no hidden lobby participant, no participant-minute burned |
| Media token | Minted only after approval, on its own endpoint, never on the poll |
| Source of truth | Postgres, never LiveKit room metadata |
| Visitor learns of approval | Pushed 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 Centrifugo | Yes — 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 request | Centrifugo office: namespace, published in the same transaction |
| Members get the current list | REST fetch, triggered by subscribing |
| Approver must be in the office | Yes, enforced server-side (§10) |
| Who removes a visitor | A 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 mode | Office or conference, chosen at invite time, server-set participant attribute |
| Trust label | Server-stamped; CanUpdateOwnMetadata denied so it cannot be rewritten |
| Email / push notification | Out of scope |
OfficeMetadata.GuestState | Not used; left in place, frozen |
| Permissions | Three, not one — AdmitVisitor (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 removal | One release, never two. A trunk where visitors can enter and cannot be removed is a broken release (§17) |
| Deleting an office | Stays possible, always. Hard delete, no deleted_at anywhere, no ON DELETE RESTRICT anywhere (§6, §15d) |
| Erasing a member or a visitor | Possible in potency, always. Every attribution FK is SET NULL; anonymise-in-place is the preferred operational path, not a schema-enforced one (§6) |
| Retention periods | Not 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 = TRUE | requires_approval = FALSE | |
|---|---|---|
| Row is born | pending | approved, auto_approved = true, approved_until stamped |
decided_by_user_id | NULL until a member decides | stays NULL — nobody decided (§6) |
use_count | untouched; consumed at §4F | incremented here, conditionally against max_uses |
| Office publish on the outbox | yes | no — there is nothing to answer |
| Response carries | realtimeToken + connectURL | neither (§9) |
| Next step | wait (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.
-
pendinglives ~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.
-
approvedstays redeemable untilapproved_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.
-
deniedis terminal for that request. -
The API status vocabulary is computed, not stored. A row that is
pendingwithexpires_atpassed reads asexpired; so does anapprovedrow pastapproved_until, and so does apendingrow whose invite was revoked. Nothing writesexpired— the column keeps the value legal for a future job that might materialise it, but no code path does today. -
A revoked invite reports
expiredon 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_invites — id, 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_requests — id, 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 occurred —
decided_atwhen a member decided it,auto_approvedwhen 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 conditional — UPDATE … 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
| Who | Sees |
|---|---|
| The claimed user | all their visitor accesses, across every organization — their own data |
| An organization | only 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.OfficeMetadataUpdater ←
office.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:
| Bounds | Answers | |
|---|---|---|
visitor_invites.expires_at | the link | until when this link lets anyone knock |
visitor_join_requests.approved_until | your return to one office | how long I may be away and still walk back in |
visitor_sessions.expires_at | the browser's identity | until 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:
GetOfficeByOfficeIDAndWorkosOrgIDis the entire fence andListOfficesis 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_untiland 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_metricswhile her member minutes sit inparticipant_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.
| Constraint | A member fetching a link gets | Needs an invite list UI | |
|---|---|---|---|
| A — per member | UNIQUE (office_id, created_by_user_id) | their own, always the same one | yes |
| B — per office | UNIQUE (office_id) | the office's one link, shared | no — one field and a Reset button |
| C — free multiple | none (what §6 specifies today) | whatever they ask for | yes |
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
| Path | Operation | Permission | In → out |
|---|---|---|---|
/offices/visitor-invites/create | CreateVisitorInvite | ManageVisitorInvites | officeId, arrivalMode, requiresApproval, expiresAt?, maxUses?, label? → inviteId, url |
/offices/visitor-invites/revoke | RevokeVisitorInvite | ManageVisitorInvites | inviteId → {} |
/offices/visitor-invites/list | ListVisitorInvites | ManageVisitorInvites | officeId, status?, limit, offset → {data, page} (never secrets) |
/offices/visitor-requests/list | ListVisitorJoinRequests | AdmitVisitor | officeId, status, limit, offset → {data, page} |
/offices/visitor-requests/approve | ApproveVisitorJoinRequest | AdmitVisitor | requestId → outcome |
/offices/visitor-requests/deny | DenyVisitorJoinRequest | AdmitVisitor | requestId → outcome |
/offices/visitors/kick | KickVisitor | KickVisitor | requestId → {} |
The two list operations land on different permissions, and the criterion is what each list is
for. ListVisitorJoinRequests is the work queue of AdmitVisitor — status=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):
| Permission | Admin | Moderator | Member | Guest |
|---|---|---|---|---|
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
| Path | Operation | Credential | In → out |
|---|---|---|---|
/visitor-access/preview | GetVisitorInvitePreview | invite secret (body) | → orgName, officeName, arrivalMode, requiresApproval. Pure read — creates nothing |
/visitor-access/request | CreateVisitorJoinRequest | invite secret (body) | displayName → requestId, status, realtimeToken?, connectURL? (+ Set-Cookie: session) |
/visitor-access/session | GetVisitorSession | session cookie | (empty) → session, claimed?, realtimeToken, connectURL, accesses[], nextPollInSeconds |
/visitor-access/token | CreateVisitorOfficeToken | session cookie | requestId → 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/token →
CreateOfficeToken. 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
| Path | Operation | Credentials | Out |
|---|---|---|---|
/visitor-access/session/claim | ClaimVisitorSession | session cookie + WorkOS access-token cookie | claimed 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 view — ListVisitorAccesses 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-Secretheader 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 callers —
resolveLiveInvite(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.OfficeMetadataUpdater ← office.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).
The cookie is an opaque secret, never a JWT
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 cookie | Realtime connection token | |
|---|---|---|
| Form | opaque 256-bit secret | signed JWT (HS256) |
| Reaches | our backend | the broker |
| Readable by JS | no (HttpOnly) | yes — the client must hand it to centrifuge-js |
| Grants | the right to ask — status, office token | subscription to one channel, nothing else |
| Revocable | yes, the row is consulted every time | no — 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
RegisterPublicdoes not mean "no credential".organization/routes.go:169registersaccept-invitepublicly 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 — withRegisterPublicthose 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:331resolvesContent-Typeagainst a format registry and returnsErrUnknownContentTypefor 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.
| Namespace | Who subscribes | How | Carries |
|---|---|---|---|
office:{orgId}:{officeId} | members, on entering an office | on demand, subscribe proxy, pure org-segment string compare | visitor.join_request, visitor.join_request_resolved |
visitor:{sessionPublicId} | one browser, while it has anything pending | server-side, pinned in the connection token's channels claim — the proxy is never involved | visitor.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:
| Shape | Verdict | |
|---|---|---|
| A. Short poll | HTTP every 2–5s on /visitor-access/session | Fallback, retained |
| B. Own namespace | visitor:{sessionPublicId} (then per-request; rekeyed by §6b) pinned in the connection token's channels claim | Chosen |
| C. The office namespace | put 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: LiveKit —
ListParticipantsat decision time. Broker channel presence is disqualified on principle (subscription is client-asserted, so a modified client could subscribe without ever joining);room_presenceis 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 separateKickVisitorpermission (§2, §7) governs who may do it; this governs from where. - Strip the dev-admin suffix.
createofficetoken.go:92-95appends__<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
CreateVisitorOfficeTokenrefuses onceapproved_untilhas 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 writingapproved_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 ownKickVisitorpermission (§7), which defaults to the same roles asAdmitVisitor— 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 callsRemoveParticipant. Nothing else is written. The row then reads asexpiredthrough the same lazy computation every other terminal state uses,CreateVisitorOfficeTokenrefuses on the next mint, and §5's "nothing writesexpired" stays true — no new column, no new status writer. The alternative, akicked_atcolumn, 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). -
RevokeVisitorInvite— invalidates the link, and only the link (settled 2026-08-17). It writesrevoked_at, so no further knock can resolve the secret and any request stillpendingon it becomes unusable. It does not touch anyone already admitted: noRemoveParticipant, 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 visitor —
user_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 map — r.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-foundapprover-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'smax_uses(§6). Deliberately not folded intovisitor-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-decided— 409, 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 andvisitor-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-claimed— 409. Claiming is one-way and terminal (§6b).visitor-not-admitted— 409, member-facing, raised byKickVisitorwhen the named request ispendingordenied: that person was never admitted, so there is nothing to eject them from. Deliberately not folded intovisitor-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:approvedand connected is the normal case;approvedand 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-Formust 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-storeis load-bearing —no-cachestill permits storage with revalidation, and without it an intermediary can serve a cachedpendingforever. - 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_connectedwill 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:
| Signal | Reads | Would detect |
|---|---|---|
| Invite-level — live invites never used ÷ invites created | links minted that nobody walks through | invite spam, compromised member account |
| Request-level — knocks approved ÷ knocks total, over approval-required invites only | people arrive but nobody lets them in | an 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_idandchat_conversation_members.user_idare 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_idis 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_idis displayable,secret_hashis never displayed — becauseListVisitorInvitesmust 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 beGenerateSecureTokenWithLength(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:96passes an empty role slug,admitguest.gois commented out end to end, andRole.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.
| # | Subtask | Depends on |
|---|---|---|
| S0 | rooms → offices rename, DB and internal/repository only. Table and columns renamed, rooms.room_id → offices.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 | — |
| S1 | Persistence: 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 deleted | S0 |
| S2 | Session 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 | — |
| S4a | Office-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 | — |
| S5 | Platform: publishable-sources grant field; sever RemoveParticipant's not-found (§11) | — |
| S3 | Member 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/deny | S1 |
| S2b | The 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 | — |
| S4 | Visitor 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 CreateRoom | S1, S2, S2b, S4a, S5 |
| S4b | Removal: 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 below | S3, S4 |
| S6 | Errors, feature module, route registration, apitags.VisitorAccess, the visitor.request_resolved declaration, contract regen, frontend handoff | S3, 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 does | What we do, and why | |
|---|---|---|
| Waiting participant | Split — Daily and Chime hold them outside, 100ms holds them inside with a restricted role, LiveKit and Agora take no position | Outside. Cost (a connected waiter burns participant-minutes) and §15b (an in-room waiter is on the data channel before approval) |
| Re-entry window | Nobody 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 window | Ours 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 eviction | No product does it. Expulsion is universally a human action by the inviting org | Same — 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 minutes | Hard caps and ratios dominate; Gather tried a pool and reversed it in 2.0 | Undecided. Build the measurement regardless — every model needs it |
| Notifying the waiter | Split — 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 objects | Push, 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-Interval | Body field. Forced by the house rule that every operation answers 200 |
| Kick tiers | Universally two — soft eject and sticky block | One, 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 invite | Nobody 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 offboarding | Org-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 shape | Person-shaped — Kumospace ships Block Guest with a "Manage Blocked Guests" unblock list; Gather restricts Ban to Admin/Moderator | Link-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 inventory | No 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 it | Both 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
HttpOnlycookie scoped to/visitor-access; the browser attaches it. There is no per-request secret. On returning to the page, callGetVisitorSessionwith 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 ofpreviewandrequest— it is never a cookie and never a header, so two invite links open in two tabs cannot overwrite each other (§8). SetReferrer-Policy: no-referreron the join page. - Branch on
requiresApproval, whichpreviewtells 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
pendingwithrealtimeTokenandconnectURL. - No approval — the knock returns
approvedand 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.
- Approval required — the knock returns
- 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
GetVisitorSessiononce. 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, callCreateVisitorOfficeTokenwith thatrequestIdonce, and again on any cold re-entry. - Fallback, only if the socket cannot be established (corporate proxy, hostile network): poll
GetVisitorSessionevery 2s for the first 30s, then 5s, obeyingnextPollInSeconds. 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'sexpiresAtto 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 freshrealtimeToken, 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
ClaimVisitorSessionwhile 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 readsexpired.approved_untilonly affects whether re-entry needs a new knock. OfficeMetadata.GuestStateis 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.
20a. Consent for the visitor session cookie — addressed, not resolved
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 NULLis 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_idandoffice_iderases nothing about them.display_nameis free text they typed andlivekit_identityis 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:
display_nameandlivekit_identitymust be NULLABLE. §6 declares them as bareVARCHARs. A scrub cannot run againstNOT 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.session_idmust 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 existingON DELETE SET NULL; nulling it explicitly as well is belt-and-braces and, more usefully, self-documenting.- §13's
UNIQUEwrinkle does not arise on this path — a point in favour of the scrub that was not visible when §13 was written. §13 warns thatUNIQUE (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 NULLstays 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. - 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.labelis 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 thisUPDATEtouches, with no key leading from a session to it.livekit_identityhas 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 targetedUPDATEover 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.