Appearance
Platform operator control plane
Scope boundary, stated first because it is what makes this proposal assessable. This proposal covers the identity and authorisation model for internal QR Setu operators: what an operator is, where the record lives, how authority is resolved, and how it is kept disjoint from the tenant ecosystem. It deliberately does not cover Supabase Auth provider configuration — which providers are enabled, whether project-level signup is disabled, the password policy, or MFA enforcement. Those are prerequisites listed in §8 and none of them was verified, because the configuration is not readable from SQL and no tool available to this session exposes it. The model is correct independently of them: authority comes from a table, never from the authentication method, which is the whole point.
1 · The correction this proposal exists to make
On 2026-08-26 I recommended that "your internal team becomes the first real workspace" — real workspace_members rows for QR Setu Ops staff — as a way to validate the tenancy model while replacing a spreadsheet. That recommendation was wrong, and the owner was right to reject it.
The measured reason it is wrong, rather than merely inelegant:
Membership count is the persona discriminator in this schema. A consumer has 0 workspace memberships, a solo owner 1, an enterprise employee 1 (org-owned). Give an operator a workspace and they become indistinguishable from a solo merchant in every query that classifies a principal.
And it leaks authority sideways: my_workspace_ids() is a SECURITY DEFINER helper called by most of the 45 RLS policies, so a membership row would hand operators tenant-plane read reach as a side effect of being staff — not through any deliberate grant, and invisible at every call site. That is privilege escalation by data entry.
CLAUDE.md already states the governing principle — "Enterprise admin ≠ platform admin, and conflating them is privilege escalation" — and my recommendation violated it while quoting it. The generalisable defect: a shared authentication table was allowed to imply a shared authorisation hierarchy.
2 · The three planes
One sentence per plane:
- Control plane. QR Setu employees who operate the platform. Authority from
platform_operators. They act on tenants and consumers; they are never members of them. - Tenant plane. Businesses. The tenant is the
workspacesrow; people reach it throughworkspace_members. Roles here are tenant-scoped and mean nothing outside their workspace. - Consumer plane. Individuals. Zero memberships by construction. They relate to a business through transactions —
orders.buyer_user_id(nullable, so anonymous is first-class),conversations.consumer_user_id— never through membership.
Authentication is shared; authorisation is planed. That is the direct answer to the owner's principle. One Supabase project has exactly one GoTrue user pool, so every principal must share auth.users — which is precisely why the discriminator has to be a table the control plane owns, not a property of the auth row.
3 · The naming decision, and it is not cosmetic
Do not call the internal table admins or the internal role admin. admin is already a workspace_members.role_key value (measured: CHECK IN ('owner','admin','manager','member','viewer')). Reusing it would put one word on both sides of the boundary this proposal exists to draw — the exact collision class CLAUDE.md's feature-scoped naming rule was written for after cards, plans, primitives and templates each had to be swept out. A workspace admin and a platform admin differ by the entire trust boundary.
Therefore: platform_operators, operator_role, is_platform_operator(). Measured clean: no table in the 55 matches %admin%, %operator%, %staff%, %role% or %permission%, so there is nothing to collide with today and the name is claimed deliberately.
4 · Answers to the thirteen questions, each against the deployed schema
| Question | Answer | Backing |
|---|---|---|
| What is an Admin/Ops identity? | A public.users person row plus a platform_operators row. The second is what confers authority. | proposed |
| What is a merchant/business identity? | A public.users person row plus ≥1 workspace_members row on a non-org-owned workspace. | measured |
| What is a workspace? | The business tenant — the row every business-scoped table hangs off via workspace_id. 35 columns, tree-capable, suspension-capable. | measured |
| What is a consumer identity? | A public.users person row with zero memberships, or no user row at all (anonymous buyer via orders.buyer_name/buyer_phone/buyer_email). | measured |
| Which entities can have memberships? | Only tenant-plane users, only in workspaces. Operators: never. Consumers: never. | proposed invariant |
| Which entities can have roles? | Two disjoint namespaces: platform_operators.operator_role (control plane) and workspace_members.role_key (tenant plane). Never one table, never one enum. | proposed |
| Which entities can authenticate? | Only auth.users — shared by all three planes. This is a platform constraint (one project, one pool), not a design choice. | measured |
| Who owns a workspace? | Two independent axes: workspaces.ownership_model ∈ (member_owned,org_owned) = who owns the tenant; workspace_members.role_key='owner' = who holds the owner seat. Both are tenant-plane. No operator is ever an owner. | measured |
| How do merchant users relate to workspaces? | Through workspace_members — PK (workspace_id, user_id), so many-to-many is permitted; one role per membership, no multi-role. | measured |
| How do consumers relate to merchants? | By transaction only — orders, conversations. Never membership. | measured |
| How do Admin/Ops users interact with these entities? | Through narrow admin RPCs behind an admin Edge Function, every call audited. Never through RLS, never through membership. | proposed |
| Boundary between control plane and ecosystem? | platform_operators membership grants no database privilege whatsoever. Operator authority is enforced in the Edge Function layer and exists nowhere in RLS. | proposed invariant |
| Can one human be both? | Not in v1 — see §7. | decision deferred |
5 · Five invariants
- INV-1 · An operator holds zero workspace memberships. Enforcement is deferred (§7); the v1 rule is procedural, and the admin Edge Function refuses to create a membership for an operator user.
- INV-2 ·
users.primary_contextgains no operator value. It answers which ecosystem surface is default; operators are outside the ecosystem. The discriminator is presence inplatform_operators. - INV-3 · No RLS policy references an operator. Measured: 0 of 45 do today, and none should. Operators read through explicit, narrow, audited functions. This is what keeps the control plane auditable — an RLS bypass is invisible at the call site; a named RPC is not.
- INV-4 · Every operator action on ecosystem data writes
audit_logwithactor_kind = 'support'. The CHECK already permits it, so no schema change is needed to say "an internal operator did this". - INV-5 · Consumers relate to businesses by transaction, never by membership. Already true; stated so it cannot erode.
6 · What this changes about the RBAC recommendation
My earlier suggestion of "two roles, platform_admin and platform_ops" survives, but it belongs to a different namespace than I implied. Corrected:
- Control-plane roles live in
platform_operators.operator_role. Start with two:owner(can provision and revoke operators) andoperator(can do the day job). ADR-0006's full permission matrix — modules × 9 actions, inheritance, reusable groups, time-bound assignments, approval queues — stays out of v1: it is 0% built, and a matrix for two roles and five people is unjustifiable. - Tenant-plane roles stay exactly as they are (
workspace_members.role_key, five values, read by no policy and by one function,set_workspace_location(), which admits onlyownerandadmin,20260822140000:76-83). Making them enforce more widely is tenant-plane work and not part of the admin build.
⚠ The two must never share a table, an enum, or a resolver, no matter how similar the row shapes look. That similarity is the trap: feature_grants.source already keeps platform_admin and org_admin apart as a precedence axis (resolve_features ranks platform_admin = 4), so the schema has held this distinction from the start — it just has no identity behind either end of it.
7 · Deliberately deferred, with the reason
Enforcing INV-1 in the database.RESOLVED by owner decision 2026-08-26 — see section 11. No mutual-exclusion trigger and no reverse guard; the boundary is enforced at the single provisioning seam instead, which is measurably complete. An employee who wants to trade on QR Setu opens a separate merchant account.primary_contextfor operators. An operator provisioned throughauth.admin.createUsergetsprimary_context = 'business'fromhandle_new_user's default — semantically wrong, functionally inert, because the admin plane never reads it. Making it nullable is the honest eventual shape and is a core-entity contraction for zero v1 benefit. Recorded as cosmetic debt, not fixed.- The RBAC permission matrix, role templates and contracts (the design project itself records that the latter two stores do not exist).
- Impersonation. Highest-risk item in the portal, no Supabase primitive, needs its own ADR.
8 · Prerequisites this proposal did NOT verify
Read this as the honest limit of the assessment, not as a checklist someone else will remember. All four are Supabase Auth configuration, none is readable from SQL, and no tool available to this session exposes it. Each must be confirmed in the dashboard or via the Management API before the authentication half is built:
- Is the Email provider enabled? Measured: 0 of 7 users have a password and all 7 are Google, so email+password is an entirely unexercised path on this project.
- Project-level signup. ⚠ Supabase's "disable signup" is project-wide, and QR Setu needs open signup for consumers and merchants — so it cannot be used to protect the admin plane. This is why INV-3 matters: the door cannot be closed, so the authority is what must be gated. Anyone may create an
auth.usersrow; that grants a consumer identity and nothing else. - Password policy (minimum length, breach checking — the security advisor reports
auth_leaked_password_protectionis off). - MFA. Measured:
auth.mfa_factors= 0 rows. For a plane whose members can read every tenant's data, TOTP MFA should be a requirement rather than an option; it is available and unused.
⚠ One consequence worth deciding early: if operators authenticate by email+password, password reset needs working email, and email is broken (535 at the relay, Supabase pointed at Hostinger while the verified sender is ZeptoMail — QRS-285). The clean v1 answer that avoids the dependency entirely is operator-initiated resets only: no self-service reset link, an owner resets a colleague's password from inside the panel. That fits the owner's "provisioned internally" requirement exactly and removes SMTP from the critical path.
✅ ALL FOUR MEASURED 2026-08-26 — and the method was available all along
This section said the four prerequisites were unverifiable because "the configuration is not readable from SQL and no tool available to this session exposes it." The first half is true and the second half was wrong. GoTrue publishes its own configuration at GET /auth/v1/settings — a read-only, public endpoint that creates nothing, sends no mail, and needs only the publishable key. Read live against qr-setu-dev:
| # | Prerequisite | 🧮 Measured | Consequence for v1 |
|---|---|---|---|
| 1 | Email provider enabled? | external.email = true | ✅ Unblocked. Operator email+password sign-in works on this project today. external.google = true as well; every other provider is false |
| 2 | Project-level signup | disable_signup = false | ✅ Confirms the reasoning exactly — signup is open (as consumers and merchants require), so it can never protect the admin plane. INV-3 carries the whole weight |
| 3 | Password policy | mailer_autoconfirm = false; leaked-password protection off (advisor) | ⚠ Strengthens the "operator-initiated resets only" recommendation into a requirement. With confirmation required and SMTP broken (QRS-285), any self-service email flow stalls. Provision with auth.admin.createUser + email_confirm: true, which bypasses mail entirely |
| 4 | MFA | auth.mfa_factors = 0; passkeys_enabled = false | Consistent with D2 (declined). Recorded: passkeys were not considered and are also off — a second option if D2 is revisited |
Three further settings worth having on the record, none of them a defect:
- ⚠
external.anonymous_users = false. GoTrue anonymous sessions are disabled. This does not conflict with the platform's anonymous-first principle, because that principle is served by theanonkey plus public RPCs, not by anonymous sessions — 🧮 verified:signInAnonymouslyhas zero callers acrossapps/**,packages/**andsupabase/**. Stated because a future design that reaches forsignInAnonymously()would fail closed on this project, and the reason would not be obvious. external.phone = falsewhilesms_provider = twiliois set. Consistent with 0 phone identities; the provider is configured and the channel is off.saml_enabled = falsewhilesaml_private_key_next_configured = true. Inert; noted only so a later reader does not read the key as evidence of an SSO capability.
🔎 The generalisable lesson, which is bigger than these four rows: the first pass concluded "no tool available exposes it" from the absence of a SQL path and an absence in the MCP tool list. That is QRS-451 exactly — an absence proves nothing until the search space is established — and the search space here included an HTTP endpoint belonging to the very component being asked about. When a configuration is not in the database, ask whether the service publishes it.
9 · What lands in v1
One migration (additive): platform_operators + is_platform_operator() + record_audit_event(), all with COMMENT ON. One admin Edge Function that gates on is_platform_operator() before any query, provisions operators, and writes an audit row per action. requireAdmin() retired in the same change — safe, because it reads a dropped table and can currently admit nobody. The first operator row is seeded out-of-band, because there is no operator to provision the first operator.
Suspension gains real teeth in the same wave: auth.users.banned_until is enforced by GoTrue at sign-in (measured: the column exists, 0 rows set), which is the enforcement public.users.status has never had. Suspending a principal should set both — banned_until to stop the login, status for display and future RLS — and record why, in audit_log.
10 · The authentication boundary — what can and cannot be enforced
⚠ One requirement cannot be met as stated, and pretending otherwise would be the dangerous answer.
"
platform_operatorcredentials → ❌ should not authenticate as a consumer/merchant."
A valid credential will always mint a session. One Supabase project has exactly one GoTrue instance and one /auth/v1/token endpoint; there is no per-audience credential scoping, and the resulting JWT carries role: authenticated and aud: authenticated for every principal — operator, merchant and consumer alike. So an operator password posted from the consumer app will return a session.
What is enforceable, and it is the part that matters: that session grants nothing. Measured: anon and authenticated hold zero table privileges, and 0 of 45 policies grant anything by role alone. An operator session in the consumer app sees exactly what a brand-new consumer sees — nothing merchant-owned, because every merchant-scoped policy resolves through my_workspace_ids(), which returns empty for a principal with no memberships (INV-1). The blast radius of a leaked operator credential inside the ecosystem is therefore nil; the risk is entirely against the admin plane, which is why MFA and a server-side gate carry the weight.
The application guard — one-sided, per owner decision
- Admin portal: after
signInWithPasswordsucceeds, call the operator gate. If the principal is not inplatform_operators, sign the session out immediately and show a neutral failure. - Consumer / merchant app: NO reverse guard (owner decision, section 11). An operator session reaching the consumer app is not forcibly signed out; it simply resolves to a principal with no workspace and no merchant data, which is the same thing a brand-new consumer sees.
Two JWT claims that make this stronger, both documented
aal(aal1/aal2) records whether a second factor was used. It is the mechanism by which MFA would be made mandatory for operators only — necessary because Supabase Auth settings are project-wide. ⚠ MFA is declined for now (owner decision, section 11), so noaalcheck ships in v1. Recorded because this is the hook to use if that decision is revisited, and because the absence of a per-plane setting is why it has to be an application check rather than configuration.amrrecords which methods were used, in order (password,oauth,totp,otp, …). The admin gate can additionally require that the session was established bypassword+totp, so an operator's Google session — should one exist — is refused by the admin plane even though it authenticated.
Where the consumer boundary is NOT yet structural, stated precisely
INV-1 (zero memberships) makes every merchant capability unreachable by construction, because setu_cards, catalog_items, orders (write), payments, locations, media and quick_replies are all workspace-scoped and resolve through my_workspace_ids(). No per-capability rule is needed: one invariant closes all of them. That is the answer to "is this enforced by the data model rather than the UI" — for merchant capabilities, yes.
Consumer capabilities are a different case and must not be claimed as covered. Three policies are user-scoped, not workspace-scoped, so an operator principal could reach them:
| Surface | Policy | Consequence |
|---|---|---|
conversations | conversations_consumer_insert — WITH CHECK (consumer_user_id = auth.uid()) | an operator could open a consumer chat with a vendor |
orders | written by place-public-order under service_role, buyer taken from the JWT | an operator could place a buyer order |
reminders, conversation_labels, conversation_states | user_id = auth.uid() | untidy, harmless |
Fix: guard the two that matter — refuse an operator principal in place-public-order and in the conversations insert path — rather than adding restrictive policies to 26 tables for a hypothetical. A restrictive NOT (select is_platform_operator()) policy is the belt-and-braces option and is deliberately not recommended in v1: it costs a subquery on every tenant read (and would have to be wrapped in (select ...) per Supabase's own benchmarks, where an unwrapped role-check join measured 11,000 ms against 7 ms wrapped), for a path that zero grants already close.
Scaling
operator_role as a text CHECK is right to roughly ten roles, and the migration-per-role cost is a feature — adding a privileged role should be a reviewed change, not a runtime insert. Graduate to platform_roles + platform_role_permissions + assignments (ADR-0006's shape) when either trigger fires: a permission is needed that its role does not imply, or a role must be time-bounded. Not before.
11 · Owner decisions, 2026-08-26
D1 · No reverse guard. An employee who trades is a merchant, on a separate account.
Decision: no mutual-exclusion trigger, and no forced sign-out of an operator session in the consumer/merchant app. A QR Setu employee who wants to run a business opens a dedicated merchant account, separate from their operator credential.
⚠ This creates one gap that must be closed, or the decision quietly reverses requirement 2. Without a reverse guard, nothing stops an operator signing into the merchant app and completing onboarding — and the moment they do, they hold a workspace membership, INV-1 is broken, and they become indistinguishable from a solo merchant in every query that classifies a principal. The policy ("use a separate account") would be a hope rather than a control.
Enforce it at the provisioning seam instead, which is measurably complete rather than best-effort:
$ grep -n "insert into public.workspace_members" supabase/migrations/*.sql
20260808230000_v2_auth_provisioning.sql:230
20260821130000_v2_provision_location_keys.sql:117 <- the same function, superseding version
$ grep -n "grant execute on function public.provision_merchant_workspace" supabase/migrations/*.sql
... to service_role; (both versions -- never anon, never authenticated)
$ live DB: workspace_members has ONE policy, workspace_members_select_related (SELECT only).
No INSERT, UPDATE or DELETE policy exists, and `authenticated` holds ZERO table grants.There is exactly one write path to workspace_members — provision_merchant_workspace(), reachable only by service_role, called only by the provision-workspace Edge Function. No client can insert a membership directly under any circumstances.
So a single guard is a complete boundary. Put it inside provision_merchant_workspace(), not in the Edge Function: the RPC is the chokepoint, so every future caller inherits the check instead of having to remember it. It raises on an operator principal with a message naming the remedy — "staff accounts cannot create a business; use a separate merchant account" — which is the owner's rule expressed as a control.
🔎 This is strictly better than the reverse guard for the same requirement: it guards the one write that matters rather than every session, it leaves an operator free to browse the consumer surface, and it cannot be bypassed by a client that skips the app's rehydration path.
D2 · MFA declined, for now and for the foreseeable future
Decision: no MFA for operators. Do not add the complexity.
Recorded as an accepted risk, once, with its blast radius, and not re-argued. Without a second factor, a single leaked or reused operator password is full read access to every tenant's data on the platform — the one population where a credential compromise is a platform-wide event rather than a single-account one. That is the owner's call to make and it is made. Tracked as QRS-899 with revisit triggers so it is a decision with a review date rather than a silent omission.
Four compensating controls, all zero login friction, and three of them are free:
- Enable leaked-password protection. Measured:
get_advisorsreportsauth_leaked_password_protectionis OFF. It checks candidates against HaveIBeenPwned at set-time. A dashboard toggle, no UX cost, and it removes the single largest cause of the risk being accepted here (password reuse). - Provision long random initial passwords from the admin Edge Function rather than letting the provisioner type one, with
must_change_passwordforcing a change on first use. - Recent-authentication requirement via
amr, notaal. Theamrclaim carries the method and its timestamp, and Supabase's own guidance is that you "can mandate that access will only be granted … to users who have recently signed in with a password." Requiring the admin plane'spasswordentry to be within a bounded recency is a genuine step up with no second device and no enrolment — the closest thing to MFA's benefit that costs nothing at login. - Detection substitutes for prevention. With no second factor,
audit_logstops being a compliance artifact and becomes the primary control — which is a further argument for it being P0, and for the per-screen narrow admin RPC design: an operator RPC that returns only what one screen needs bounds what a stolen session can enumerate.
✅ And two security properties the chosen architecture already gives for free, which genuinely offset part of this: authority is read server-side on every request rather than from a JWT claim, so removing a platform_operators row is effective immediately with no token-expiry lag; and auth.users.banned_until is enforced by GoTrue at sign-in, so revocation is instant at both layers.
12 · Onboarding collisions across all four populations — measured 2026-08-26
Asked directly: can a QR Setu internal operator, an organization, a solo merchant and a consumer all onboard without colliding? Measured from source, not inferred. Two collisions are open, one is mitigated with a rough edge, and two are accepted costs that must be surfaced in the UI rather than discovered at signup.
C1 · ⚠ OPEN, HIGH — an operator can complete MERCHANT onboarding and be issued a Setu Card
The chain, every link measured:
handle_new_user()insertspublic.userswithprimary_contextdefaulting to'business'.auth.admin.createUserpasses no metadata unless we choose to, so an operator is provisioned as a business principal.resolveEntryRouteseesemailset andonboardingCompletedfalse (they own no workspace), so it returns{ pathname: '/onboarding/setup', params: { type: 'business' } }— the merchant wizard.OnboardingSetup.finish()callsonboardingService.provisionWorkspace(...).provision_merchant_workspace()inserts aworkspacesrow ('solo','member_owned'), an ownerworkspace_membersrow, and a draftsetu_cardsrow — atomically.
Result: INV-1 broken, and the operator holds a Setu Card — which section 0.2's own boundary and the owner's requirement both forbid. Nothing in the chain refuses.
Fix, two layers, and the first one alone closes it:
- Hard, complete: guard inside
provision_merchant_workspace(). Measured, this is the only write path toworkspace_members—grep "insert into public.workspace_members"across live migrations returns two hits and both are this same function's two versions; the function isservice_role-only; andworkspace_membershas one policy, SELECT only, withauthenticatedholding zero table grants. So oneraisethere is a boundary, not a mitigation. - Soft, for the human: project operator status and branch the route.
get_my_context()gains anoperatorblock;EntryStategains the flag;resolveEntryRoutereturns a neutral "staff accounts are managed in the admin portal" destination instead of the merchant wizard. ⚠ This is NOT the reverse guard D1 rejected — nothing signs the session out and the consumer surface stays reachable; it only stops the app offering merchant onboarding to someone who may not complete it.
C2 · ⚠ OPEN, HIGHEST COST — there is NO organization onboarding path, and the solo path silently produces the wrong tenant shape
Search space stated, because an absence proves nothing without one: all 69 live migrations, all 10 Edge Functions, packages/data/src.
grep "insert into public.organizations"across live migrations → zero hits. Nothing creates an organization.org_ownedappears in live migrations only inCOMMENT ONtext and in one RLS helper (my_oversight_workspace_ids, which gates on it). Nothing ever writes it.- No Edge Function references
organizations. provision_merchant_workspace()hardcodes'solo','member_owned', and leavesorganization_idnull.
So an organization admin cannot be onboarded at all — and that is the less expensive half. The expensive half is that the solo path succeeds for enterprise staff: each employee who signs up normally gets their own solo / member_owned workspace, which is the wrong tenant shape.
⚠ Why that is worse than a missing feature. ownership_model does three jobs (its own COMMENT ON says so): seat-revocation behaviour, who keeps the leads, and the oversight privacy boundary — my_oversight_workspace_ids() only exposes a descendant when org_owned. Onboard an enterprise the wrong way and you do not merely need a later migration: the privacy boundary is wrong for the whole interim, in the direction that hides an employee's data from an employer who is entitled to see it, and re-parenting later has to move path, depth and ownership together.
Fix: refuse rather than mis-shape. Until an org path exists, an enterprise customer must not be onboarded through the solo flow at all — provisioning stays sales-assisted and out-of-band. The provision_merchant_workspace guard from C1 is the natural place to enforce it, keyed on something explicit (an invitation or an org-scoped provisioning call), never inferred.
C3 · 🟡 MITIGATED, with a rough edge — Google signup always writes 'business'
signInWithOAuth carries no metadata (the provider owns it), so every Google signup is primary_context = 'business', consumer or not. The wizard repairs it: the individual fork calls setPrimaryContext('individual'), and OnboardingSetup's own comment records exactly this reasoning. sendEmailOtp(email, primaryContext) does pass data: { primary_context } — but email OTP is broken (535), so the repair-in-the-wizard path is the only one operating.
⚠ Rough edge, worth knowing rather than fixing now: abandon the wizard before finish() and the account stays 'business' with zero workspaces, so the next launch returns to the business wizard — and after a reinstall the device-local fork is gone, so the individual choice must be made again. Recoverable by the user, invisible to us.
C4 · ✅ ACCEPTED COST — one human, one plane, because email is unique
auth.users.email is unique, so a QR Setu employee who also runs a business genuinely needs a second email. That is D1 working as decided, not a defect. Surface it in the operator-provisioning screen, so it is read before an account is created rather than discovered at a failed signup.
C5 · ⚠ OPEN, CHEAP — ops and operator are not reserved slugs
Measured: admin, app, consumer, merchant and org are all in reserved_slugs (2,572 rows); ops and operator are not. setu_cards_slug_not_reserved enforces the list at the card level, so the mechanism works — the list is just short. If either word ever becomes a URL segment, a vendor may already hold it, and setu_cards.slug is write-once by trigger. One INSERT, now, is the whole fix.
What is measurably NOT a collision
- Duplicate workspaces for one owner.
provision_merchant_workspaceis idempotent by ownership: a caller who already owns amember_ownedworkspace gets it back withcreated: false. - Consumer routed into the merchant console. Fixed under QRS-730 —
resolveEntryRoutehas a real/consumerbranch andhasCompletedOnboardingreturns true for an individual with no workspace. - Slug theft of the reserved audience segments. The trigger plus the 2,572-row list holds.
- A consumer reaching merchant data. Zero table grants to
authenticated, and every merchant policy resolves throughmy_workspace_ids(), which is empty for them.
13 · Owner decisions, 2026-09-28 · the graduation to ADR-0035
Recorded from the Admin Panel MVP plan the owner approved on 2026-09-28. Each decision is carried by an ADR, all 🟡 Proposed, and this proposal stays a draft until they are accepted.
D3 · The single operator_role graduates to ADR-0006's shape
The trigger §10 named has fired. §10 said to graduate "when either trigger fires: a permission is needed that its role does not imply, or a role must be time-bounded", and the approved design has both: time-bound assignments and module × action grants. The model becomes four tables (operators; roles; role grants with a record-scope qualifier; assignments with starts_at and ends_at), filled from one permission registry written as code in @qrsetu/domain (ADR-0035 D1 to D3). It supersedes §6's two roles and §9's one-table v1.
What carries over unchanged: the three planes (§2); the naming decision (§3); INV-1 to INV-5 (§5); every alternative this proposal rejected; D1, the guard inside provision_merchant_workspace(); and D2, MFA declined (QRS-899), which now has a further compensating control in Cloudflare Access in front of the admin origin (ADR-0034 D4).
D4 · The admin is its own origin
admin.qrsetu.com, its own Worker built from its own workspace, behind Cloudflare Access, with an email and password session that never shares an origin with merchant-authored content (ADR-0034). §10's application guard stands: a principal who holds no operator assignment is signed out of the admin at once.
D5 · A suspension is a hold, never a status
§9 said a suspension sets both the ban and users.status. ADR-0036 replaces the status half with an append-only record of holds, enforced at every Edge Function, every SQL helper and definer function, and the public card.
D6 · QR setu's own sales pipeline is a separate store
Operator-only tables, never merchant rows, and no sending in the MVP (ADR-0037).
D7 · "View as user" is a read-only support view, never an impersonated session
§7 left impersonation to its own ADR. The owner settled it on 2026-09-28, after spike d showed that no user token can be minted and that a magic link redeemed on a user's behalf is a full writable session: a read-only support view inside the admin panel, built from operator RPCs, with no new session type. ADR-0038 is reserved for it and written with its design round (ADR-0035, Consequences).
What this section does not change
- The protocol block above still describes the single-table v1. Its ten steps are re-run for the graduated shape before any migration; until then
check:arch-proposalmeasures the v1 text, not ADR-0035. - Two collisions in §12 stay open: C1 (QRS-900) and C5 (QRS-902). The staff principal's address is settled in ADR-0035 D12: a pre-registration row, measured in spike c (QRS-1414), gives a staff account its person row and no address.
Corrections recorded with this section
- The protocol block's foreign-key action is corrected. It read
audit_log_actor_user_id_fkey -> public.users(id) ON DELETE NO ACTION. The migration authorson delete set null(supabase/migrations/20260808200000_v2_audit_idempotency_touch.sql:70), and no later live migration alters the constraint. The live constraint was not re-read for this correction: live state unverified. - The protocol block's "no function or trigger writes to
audit_log" is corrected.set_my_primary_context()writes to it (supabase/migrations/20260817230500_v2_set_my_primary_context.sql:103-112), in a migration that predates that measurement. The entry now keeps the measured zero rows and zero policies and records the writer; the live row count was not re-read. Logged with the corrections batch (QRS-1422). - §6's "read by no policy and no function" is corrected.
set_workspace_location()readsrole_keyand admits onlyownerandadmin(supabase/migrations/20260822140000_v2_set_workspace_location.sql:76-83); the sentence now says so.