ADR-0005: Visitor Access — Join an Office Without an Account
Status: Accepted
Date: 2026-08-19
Deciders: Team
This ADR condenses the visitor access implementation plan, which carries the full flow, schema, API surface, and build order.
Context
Qubital offices are reachable only by members of the owning organization: every participant resolves to a WorkOS user, a private.users row, an org role, and an RBAC principal in the request pipeline. That excludes the most common reason to open an office at all — a client, a candidate, or a contractor who joins one call and never comes back. Asking them to register is the wrong price for a fifteen-minute conversation, and provisioning them as members pollutes WorkOS, the org directory, and the seat count with people who are not staff.
The registered-guest tier does not solve this. permissions.RoleGuest already exists as a shipped wire value on OrganizationRole, it means a registered WorkOS user with a reduced org role, and it is currently unreachable in code. It is a different concept aimed at long-lived external collaborators, and it still requires an account.
So the question is what an unregistered participant is: what authenticates them, what row represents them, who lets them in, who can put them out, and who pays for the minutes they consume. Every one of those answers has to hold without a user row, and the security spine cannot be retrofitted — a design that admits strangers first and adds containment later is a rewrite, not an iteration.
Decision
Ship visitor access: a person opens a link, types a name, asks to enter, and a member already in the office lets them in. No registration, no email, no WorkOS user.
The link is a doorbell, not a key
Holding the link lets you ask to enter; it never admits you by itself. That single property makes link lifetime a product choice instead of a security risk — a link can be forwarded, or posted in a shared document, and the worst a stranger does is ring a bell a member ignores.
Approval is a per-invite boolean the creating member chooses, because walking a client straight in is a legitimate trade. What the schema forbids is the one lethal combination: a table constraint rejects requires_approval = FALSE AND expires_at IS NULL. A link that never expires and needs no approval is a key. For a no-approval invite, expires_at and max_uses stop being conveniences and become the only controls there are.
Attribution survives either shape. With approval, the admission is the grant and decided_by_user_id names who made it; without approval, the invite is the grant and created_by_user_id names who made that. In both cases a human in the organization performed the granting act, which is what NIST AU-3 asks for.
The feature is called visitor, never guest
Forced by a constraint rather than chosen: guest is already a shipped, frozen wire value meaning the registered reduced-role user. Two guest-named concepts for two different principals in one permission catalogue read by one RBAC.Can is how a distracted review becomes a security bug.
The industry does not agree on the word — Teams, Slack, and Miro use "guest" for the registered external; Zoom and Gather use it for the unregistered one. Miro's split is the one adopted here: Visitors hold no account and arrive by link, Guests hold an account. Permission names follow: AdmitVisitor, ManageVisitorInvites, KickVisitor — none of them guest-named.
A waiting visitor is outside LiveKit entirely
The pre-join screen is not a hidden lobby participant. A waiting visitor burns no participant-minute, holds no data channel, and cannot observe the office before admission. The media token is minted only after approval, on its own endpoint — never on the status read, never in a cookie, never in a URL.
Postgres is the source of truth for admission, never LiveKit room metadata. OfficeMetadata.GuestState is left in place and frozen; this feature does not use it.
Identity is a browser-scoped session, claimable later
A visitor's principal is a visitor session: one browser, one pseudonymous session, spanning many offices across many organizations. The session secret is an opaque 256-bit value in an HttpOnly cookie scoped by Path; the invite secret travels in the request body and is scrubbed from the URL with history.replaceState(). Neither is a JWT, and there is no per-request secret.
Claiming attaches a WorkOS identity to a session through an explicit POST presenting both cookies. It is one-way and terminal, it rotates the session secret in the same transaction (an unrotated secret would be textbook session fixation on a shared machine), and it is retroactive by construction — every past admission of that session resolves to the claimed user without a backfill. Claiming adds identity; it never removes visitor status.
display_name and livekit_identity stay on the join request, never on the session. Per-session, one identity string would appear in the room metadata, webhooks, and recordings of different organizations, letting two tenants compare artifacts and learn the same person visited both.
org_id is the tenant fence, not a read optimisation
Three tables — visitor_sessions, visitor_invites, visitor_join_requests — plus a visitor_activity_metrics sibling owned by metering. The FK rule reads in one line:
org_idpoints up and is never nulled; every other FK points sideways and is always nullable.
org_id BIGINT NOT NULL … ON DELETE CASCADE is carried redundantly on every table because invite_id and office_id become NULL: deleting an office cascades the invites away and orphans the pointers, and a derived org_id would be underivable from that moment — leaving rows that are unbillable, uncollectable by tenant, and not excludable from another tenant's queries. It also makes tenant obliteration free: removing an organization removes every visitor row it ever produced.
Two product constraints rule out ON DELETE RESTRICT anywhere in the feature: an office must stay deletable, and any datum must be obliterable in potency. RESTRICT contradicts both by construction, and the house already decided it this way in migration 000033 when recordings.initiated_by moved off RESTRICT. The cost is stated rather than discovered: hard-deleting a member destroys the audit fact of which member created a link or admitted a visitor, which weakens NIST AU-3 and ISO 27002 5.18 at that moment. GDPR Art. 17(3) permits retaining the fact but does not require it, so the mitigation is operational — anonymise-in-place on private.users is the preferred erasure path, keeping attribution resolvable.
Both sides are pushed, over channels that carry nothing extra
Members learn of a knock on the office: namespace, published in the same transaction as the row. The visitor learns of the outcome on one channel of their own, visitor:{sessionPublicId}, carried as a server-side subscription in the token's channels claim so the subscribe proxy is never involved.
A visitor connection token must never carry meta.org. That claim is the proxy's zero-DB authorization source, compared as a plain string against the channel's org segment; a real org id would position a visitor to subscribe to every member's presence channel. Left empty, the existing check denies fail-closed — so a separate IssueVisitorConnectionToken, with no parameter for an org, exists precisely so it cannot be filled by accident.
Polling is the fallback for networks where the websocket cannot be established, paced by a server-driven interval in the response body (the house rule that every operation answers 200 rules out Retry-After). A single session read serves three jobs: the baseline after the socket opens, the fallback poll, and realtime-token re-issue.
The state machine is lazy, and every writer is a human action
Time transitions are computed at read time; nothing writes expired. The writers are approve, deny, kick, and revoke — plus the knock on an auto-approving invite, which is a human action taken earlier by whoever created the link. A pending request lives about fifteen minutes, long enough that a member walking into the office still finds it. approved_until bounds re-entry, not the live session.
Revocation reaches pending requests and stops there: a row already approved is past the gate, and removing an admitted visitor is a separate, per-person act.
Three permissions, and removal is a human action
ManageVisitorInvites (create, revoke, inventory) defaults to admin and moderator; AdmitVisitor and KickVisitor also reach member, because admitting is the job of whoever is in the office rather than of whoever administers the organization. All three are false for RoleGuest without exception. Approving, denying, and kicking additionally require the acting member to be present in the office, checked server-side against LiveKit participants.
Nothing disconnects a visitor on a timer — no inactivity clock, no quota, no reaper. The organization that issued the link bears the connected time and manages overage commercially. This matches every product surveyed: Zoom, Teams, Meet, Gather, and Kumospace all leave expulsion of a specific person to a human. KickVisitor is the one removal action and does both halves unconditionally — it writes approved_until = now() and removes the LiveKit participant. RevokeVisitorInvite closes the link and removes nobody.
Entry and removal ship in one release. A trunk where a stranger can enter an office and nobody can put them out is a broken release even though it compiles — which under ADR-0004 is the case that matters.
Metering is a sibling table, not a column on the visitor row
Connected time must be attributable to a tenant, and participant_activity_metrics cannot take a visitor: its user_id is NOT NULL with a hard FK to private.users. A visitor_activity_metrics sibling is keyed on the join request and scoped to both the org and the office. One approval yields many connection intervals, so billing data must not depend on the lifecycle of an operational row. With nothing evicting on a timer, this measurement is the only cost control the design has, so it ships in v1 rather than in hardening.
Alternatives Considered
- Provision visitors as WorkOS users — rejected: it pollutes the source of truth for staff with people who join one call, consumes seats, and puts strangers in the org directory. It also makes erasure a WorkOS problem rather than ours.
- Reuse the registered-guest tier (
RoleGuest) — rejected: it still requires an account and an org role, which is the cost this feature exists to remove. It stays a separate future feature whose allowlist hangs on a membership, not on a capability. - Hold the waiting visitor inside LiveKit with a restricted role (100ms's model) — rejected: a connected waiter burns participant-minutes and sits on the data channel before anyone approves them. Daily and Chime hold them outside; LiveKit takes no position.
- A link that admits directly, with no knock — rejected as the default: it makes link lifetime a security property, so a forwarded link becomes a key. Retained only as an opt-in that the schema forces to carry an expiry.
ON DELETE RESTRICTon the attribution FKs — considered and reversed. It would make a creator or decider impossible to hard-delete and so force anonymise-in-place, turning compliance posture into something the database enforces. Rejected because it makes an office undeletable to protect a child row and denies obliteration in potency, and because the house had already decided the other way in migration 000033.- A per-request secret instead of a session — rejected: one cookie cannot carry a per-request secret once a browser holds requests at several offices, and two invite links in two tabs collide in the cookie namespace. The session gives one secret instead of N with the same protection.
- The invite secret in a cookie or a header — rejected: a cookie undoes the URL scrubbing the pre-join screen performs, and a header adds a CORS allowlist entry. In the request body it is typed in the generated contract and validated by Huma struct tags.
- A per-member or per-office singleton invite — rejected in favour of free multiples, following Discord's
uniqueparameter. A singleton has no expiry, no use cap, and no creator worth listing, and the invite inventory is one of the few places this design is ahead of every product surveyed. - A time-based eviction sweep — deferred, with a note on how it would be built. No surveyed product evicts an individual guest on a timer.
- A ban list — rejected: it is meaningless against an unidentified principal. The sticky tier is therefore link-shaped, which is weaker the more links live per office, mitigated by re-entry costing a fresh human approval.
Consequences
- A new principal exists in the system that has no user row. Visitor requests authenticate against a session cookie through their own middleware and their own typed session, sharing nothing with the member access-token claims. Every feature that assumes "a participant is a
private.usersrow" now has a second case to consider. - Visitor chat, presence, and analytics are out of scope for exactly that reason:
chat_messages.sender_idandchat_conversation_members.user_idare hard FKs, so admitting visitors would mean a polymorphic sender on the busiest table in the product. In-office text chat rides the LiveKit data channel instead, and is frontend work. - An office stays deletable and any person stays erasable, at the cost of orphaned-but-tenant-keyed rows. Erasing a visitor is a scrub of
display_nameandlivekit_identity, not a nulled FK — those columns survive every cascade described above. - Retention periods are not decided. The schema is shaped so a sweep is cheap to add later: a tenant key on every table, a named retention anchor per migration, and identifiers segregated from aggregates.
- Consent for the visitor session cookie is an open obligation. The cookie is persistent by construction, and WP29 Opinion 04/2012 excludes persistent authentication cookies from the CRITERION B exemption by name. This is addressed later, not resolved here, and it needs qualified counsel rather than more research — as does which entity is the controller for a session that spans organizations.
roomsmust be renamed toofficesfirst. The visitor migration writes office FKs, so landing it before the rename means two migrations on three newborn tables instead of one.- Two platform changes fall out of this: a publishable-sources field on the LiveKit video grant, so the visitor grant enumerates sources explicitly instead of widening itself on an SDK upgrade, and severing
RemoveParticipant's not-found intoapperr.ErrNotFound— without which removing an already-disconnected visitor errors and logs at ERROR, which is the common case rather than the edge one. - Frontend rules become load-bearing, not cosmetic. The pre-join page carries
Referrer-Policy: no-referrerand scrubs the secret from the address bar and history; the client stores no secret anywhere; all visitor-facing operations sit under a single/visitor-access/*prefix so per-IP limiting, bot filtering, and a tighter WAF posture attach at the edge by path. - Deliberate divergences from the industry are recorded so a later review does not mistake a choice for a convention: the waiter stays outside, nothing evicts on a timer, there is one person-shaped kick rather than the universal soft-eject-plus-sticky-block pair, invites are org-owned and creator-attributed so a link survives its creator's offboarding, and the re-entry window has no reliable precedent to copy — Gather's ~12 hours is single-sourced and its 24-hour figure is legacy 1.0 documentation.