Appearance
Admin Panel MVP: assessment and staged plan
Approved by the owner on 2026-09-28. This is the published assessment of the Admin Panel MVP: staff sign-in and four desks (Access control, Overview, Users, Leads & CRM). It holds the evidence (§3 to §6), the Principal Architect review (§7), the decisions (§8) and the staged plan (§9). The review and the revised plan supersede the working plan's earlier MVP boundary where they differ, and they replace its first task list and its "what happens on approval" steps, which are not reproduced here (§9.7 maps their ids). Programme row: QRS-1404. Every
QRS-###on this page is a row in the tracker. QRS-1404 to QRS-1428 and QRS-1431 to QRS-1435 were filed with this programme; QRS-1429 and QRS-1430, filed the same day, are other work (§13).
This page supersedes the 2026-08-26 backend assessment for the MVP scope
Admin Portal backend readiness assessment measured live qr-setu-dev on 2026-08-26 and remains the baseline for anything this page does not re-read. Two things in it no longer hold. Its inventory (10 Edge Functions, 69 migrations) is a month old. Its statement that audit_log has zero writers was already wrong that day: the 58th of those 69 migrations, 20260817230500_v2_set_my_primary_context.sql:104, writes to it. The correction is batched in QRS-1422.
How to read the evidence. Each claim carries one of these labels, and this page never upgrades one:
| Label | Means |
|---|---|
| design-read | read from the design files pulled on 2026-09-28, with file:line |
| repo-read | read from a repository file (code, migration, portal page), with file:line. It says what the repo declares, not what a live project runs |
| measured | a command run against a live system (DNS, a project setting) and its output. The Stage 0.5 spike results (§9.3) are measured; their output is on the spike results page |
| owner-reported | stated or shown by the owner |
| expected, unverified | an inference that nothing has checked yet |
| pending spike (a) | answered only by spike (a) (§9.3), the one spike still open on 2026-09-28 |
1 · Status and scope
| Status | Approved 2026-09-28. A plan: no admin code exists, and none is written before design round 1 comes back approved. This page is one of Stage 0's deliverables (§9.2). Spikes (b) to (e) were measured on 2026-09-28 and the owner decided on their results the same day (§9.3, §8.6); spike (a) is still open. |
| In scope | Staff sign-in, the first screen staff see, and four desks: Access control (RBAC), Overview, Users, and Leads & CRM, which the owner added the same day. |
| Out of scope | The other 11 desks of the 15-desk nav. They stay findable and say "not ready yet" (R-05 availability; §4 D6). Enterprise and organization administration (Q4). Sending any message (Q6). Editing platform policies, which lives in Mission Control. |
| Whom it serves | QR setu's own staff, the platform operators. They are none of the three user categories (CLAUDE.md §2: an enterprise admin is not a platform admin). The desks act on category 1 (business owners) and category 3 (consumers); Leads acts on prospective category 1 merchants. Category 2 administration is excluded (Q4). |
| Environments | Dev (qr-setu-dev) only. Nothing reaches Prod without the owner's ask and a Change Record (R-02, R-06). |
| The owner's sequence | latest approved design → backend and architecture assessment → gap analysis → design corrections → the finalized task plan → implementation → validation. This page covers everything up to the finalized plan. |
The owner's decisions, in one list
All dated 2026-09-28 unless marked. The detail and the consequences are in §8.
- Q1 Staff sign in with work email and password. No MFA: declined on 2026-08-26 (QRS-899) and reconfirmed for cost. Q1 also named a forced change at first sign-in; Q7 supersedes it, because with a set-password link the person sets their own first password and no temporary password exists (G5).
- Q2 Access control ships the system roles as seeded, read-only data, the permissions catalogue, permanent and time-bound assignments, and the audit log. Custom role editing and Approvals are reserved.
- Q3 User actions: suspend and reactivate, block and unblock, sign out all devices, and view as user (now the support view, item 18). The rest are reserved.
- Q4 Access control covers platform staff only. Organization and workspace scopes and the External Partner role move to the enterprise track.
- Scope addition Leads & CRM is in the MVP.
- Q5 Sign-in is only partly designed. A standalone screen and its states go into design round 1, first.
- Q6 Leads & CRM is a pipeline only. Sending, consent and segments are reserved.
- Q7 Staff credentials arrive as a set-password link (invite and reset), sent automatically through ZeptoMail. Nobody is ever emailed a password.
- Q8 Platform leads get a dedicated table that never shares rows with merchant leads, linked to the account it produced when Won.
- Origin The panel lives at
admin.qrsetu.com, as a new client-rendered workspaceapps/admin, behind Cloudflare Access, which is required. - Password policy Enforced project-wide in Supabase Auth, after measuring how many non-staff accounts hold a password.
- View as user Spike first, ship last. The spike ruled out a real impersonated session (item 18).
- Topology Two Workers per environment, six in total.
- Operations Handbook A read-only documentation site for the operations team at
admin.qrsetu.com/handbook/, written only by Claude. - Delivery Independently shippable increments, with three stops for the owner.
- Standing rule Every design gap found at any later point goes back through Claude Design before its flow is built (§10).
- Holds, on the spike results Suspend and Block are both a ban plus a session delete. The account holder sees one generic state, with no reason and no kind. A workspace hold is enforced in our own layer.
- Support view, on the spike results View as user becomes a read-only support view inside admin, with its own design round and ADR-0038, shipping last. No impersonated session is built.
2 · The verdict in one paragraph
The approved design is detailed enough to build from, but not as drawn. Staff sign-in exists only as a modal over a desk with most of its states missing; four role models disagree; the staff lifecycle that sign-in promises is not drawn; and the Overview aggregates 13 desk cores while the MVP builds three desks besides the Overview itself. The backend has no admin identity of any kind (requireAdmin() reads a dropped table and admits nobody), suspension enforces nothing, and signing a user out of every device has no working primitive. The Principal Architect review therefore found the first plan not production-ready as written and named six blockers: the panel would share an origin with merchant-authored content while MFA is declined; "suspend" would change a column that two functions read and nothing enforces; staff would be provisioned as merchants with a public address; roles would be seeded before the design settled them; impersonation rested on minting a user token that may be impossible; and audit rows would be written best-effort. The approved revision answers each one. The panel moves to its own origin, admin.qrsetu.com, as a new client-rendered workspace behind Cloudflare Access; account holds replace status churn and ship with their enforcement; one permission registry in @qrsetu/domain generates the database seeds; View as user was spiked first, and because the spike showed that a user token cannot be minted, it becomes a read-only support view inside admin, shipped last; every action commits together with its audit row. Delivery is five independently shippable increments, after a decisions stage, five measured spikes and design round 1, with the owner deciding at three stops.
3 · What the approved design contains
Source. The Claude Design project "QR setu prototype" (633dc069-6df8-4408-b625-068907c60c33), folders prototype/admin-panel/ and prototype/platform/, pulled fresh on 2026-09-28 into the session scratchpad. The pull is not durable: re-pull with list_files, then get_file. No local mirror of the admin panel existed before (D:\DevCache\design-mirror has none). Everything below is design-read from the artboards and their modules, except where a row says a file was not in the saved pull.
| File | Size | What it is | Re-read for this page |
|---|---|---|---|
admin-panel/admin-shell.js | 80 KB, 1,073 lines | the shell every desk loads | yes |
admin-panel/RBAC.dc.html | 103 KB, 815 lines | Access control | yes |
admin-panel/Users.dc.html | 134 KB, 1,691 lines | Users | yes |
admin-panel/Leads.dc.html | 190 KB, 2,146 lines | Leads & CRM | yes |
platform/leads-core.js | 60 KB, 930 lines | the lead model: stages, roles, contact policy | yes |
admin-panel/Overview.dc.html | read inline in the session | the Overview | no: not in the saved pull; checked against SCREENS.md only |
platform/users-core.js, segments.js, desk-records.js, ops-signals.js, capabilities.js | read in the session | the Users model and its action registry, saved views, the desks' records, the cross-desk aggregate, the capability ledger | no: not in the saved pull; checked against SCREENS.md only |
admin-panel/leads.spec.md; platform/controls.js, contacts.js | the Leads spec; the policies and contacts that leads-core.js imports (:19-20) | no | |
SCREENS.md | 255 KB | the design registry | yes, for the rows above |
3.1 The shell (admin-shell.js)
- Six nav groups, 15 desks (
:71-93): Command (Overview, Mission Control), People & accounts (Users, Workspaces), Growth (Leads & CRM, Ad Manager, Trending, Affiliates), Money (Subscriptions, Plans & limits, Billing), Content & channels (Templates, Communications), Trust & governance (Moderation, Access control). The current location is marked on the item, on its group and in the header. Groups and the rail collapse and remember their state (:33). - A command palette (Ctrl or ⌘ K,
:703) that searches desks and quick actions, never records (:692); a quick-actions popover (:98-104); a notifications popover of platform events (:140-145); and reminders, the operator's own, created, edited and deleted in place (:105-139). - Responsive at 1280, 1024 and 720 (
BP,:32): an off-canvas drawer below 1024 and a single column below 720. Light and dark themes. A Comfortable and Compact density setting on the Overview. - Deny by default. A desk the role cannot open is absent from the nav and the palette, and opening its URL shows
denyScreen()(:1017-1033).canOpen(href)is the single check (:168). - 7 roles in
ROLES(:152-164): Super Admin (all desks), Platform Administrator (all desks, no write onrbac), Operations Manager (9 desks), Finance (6), Marketing (6), Support Agent (4), Auditor (all desks, read only). The comment above it says the desk lists mirror the grants inRBAC.dc.html(:150). They do not (D3).
3.2 Staff sign-in
Verdict: partly designed, and there is no sign-in screen of its own. list_files on 2026-09-28 shows no SignIn or Login artboard in prototype/admin-panel/. Sign-in exists only as signInModal() inside the shell (admin-shell.js:805-887), which is drawn on top of whichever desk was opened, with the desk loading behind it (start(), :1035-1038).
| # | Element | Designed? | Evidence |
|---|---|---|---|
| A1 | Credentials step: the QR setu Admin brand, "Sign in to Admin", the no-sign-up and no-phone explainer, work email, password with show and hide, Sign in, Enter submits, autofocus | yes | :805-887; the explainer at :827 |
| A2 | Client validation: email format, and a password of "at least six characters" | yes, with the wrong rule | :872. The change-password policy is ten characters, a number, mixed case and a symbol (PW_RULES, :907-912). The two rules contradict each other |
| A3 | "Forgotten your password?" opens a note: no self-serve reset, ask a Super Admin or Platform Administrator in Access control | yes | :841-844. The note ends "the new password is sent to this address": an emailed password (G2). No reset flow exists at the other end (D1) |
| A4 | A role picker, "Continue as" | demo only | :845 onward. Must not ship: the role comes from the assignment |
| A5 | Change password: current, new and confirm fields, show and hide, four live rules, a match indicator, Save disabled until valid, four error messages, the toast "Password changed. Other devices have been signed out." | yes | :907-1012, the toast at :1004. Esc and the backdrop close it (:1008-1009), which is right for a voluntary change only |
| A6 | Profile menu: name, email, the role card, the Roles link, Change your password, Sign out | yes | :463-484. "Back to the prototype hub" (:479) is prototype-only |
| A7 | Deny screen: "This desk is not part of your role", with a way to the first allowed desk | yes | :1017-1033 |
The five missing pieces, G1 to G5, are in §4; G5 has since been superseded by Q7.
Staff accounts and the tenant plane (the plan's note, expected, unverified when it was written): handle_new_user gives every new auth user a public.users row with primary_context = 'business' and a public slug (QRS-1078), so a staff account created by auth.admin.createUser or by an invite would get a merchant-shaped principal with a Setu Card address. Staff creation must bypass or branch that path, and it overlaps C1 (QRS-900). Repo-read on 2026-09-28: the trigger's latest definition does exactly that, seeding a business account's slug opaquely (20260905233000_v2_every_account_has_an_address.sql:187-234). Spike (c) then measured the way through (§9.3): staff provisioning uses a service-role pre-registration row that the trigger consumes, and user_metadata, which the client can write, never grants anything (B3, QRS-1414).
3.3 Access control (RBAC.dc.html)
- Five tabs (
:672): Roles, Permissions, Assignments, Approvals, Audit log. - Roles: a library in grid or list view, search, filter, bulk retire and activate, and create, clone or inherit. Each role has three sub-tabs: the matrix (19 modules × 9 actions, with inheritance, reusable permission groups, and dependency and conflict validation), members and settings. Status is active, draft or retired (
:521). Delete goes through a confirm dialog (confirm-delete.js). - Permissions: the catalogue, with 6 reusable groups (
PERMGROUPS,:499-506), the action taxonomy with risk levels (ACTIONSandARISK,:472-474), the module registry (MODULES,:476-496, 19 rows) and deny by default. - Assignments: time-bound or permanent, with an expiring state. The "Assign role" modal (
:385-402) takes the Person as a free-text full name (:391), then a Role, a Scope target, a Duration, and Starts and Ends as text inputs with the placeholders2026-07-18and2026-08-18(:398). - Approvals: the queue for sensitive changes, seeded from
desk-records.jsACCESS_REQUESTS(:536). - Audit log: search, filter (
:769), export. - The model: 9 actions (
view,create,edit,delete,approve,publish,export,configure,manage) with dependencies (ADEPS,:473; for example publish needs view and edit); 3 scopes, platform, org and workspace (SCOPES,:497); 10 system roles (SYSROLES,:508-519), including Developer / API, Customer Success and External Partner, which the shell does not have.
3.4 Users (Users.dc.html)
Seven tabs (TABS, :1372-1374) plus a record view:
- Pulse: registered, MAU with DAU/MAU, paying with conversion, churned; the five-population split; twelve months of engagement; the lifecycle distribution; acquisition sources; a "needs a person" rail.
- Directory: one list mixing persons and accounts, with
kindas a facet. Saved views are the primary navigation. Search across name, phone, email and handle; 6 facets; 5 sorts; paging at 25; bulk selection and actions. - Record: identity and status, lifecycle beside login activity, the ecosystem chain (every link opens its owning desk), a merged timeline, the action rail, and "not available here" on three separate axes: applicability, state and permission.
- Segments (a rule builder, handed to Communications), Cohorts, Churn & conversion, Interventions (a ranked queue) and Audit.
- 11 actions (
users-core.jsACTIONS): message, verify, plan, reset, signout, transfer, impersonate (read-only, 25 minutes, the user is told), suspend, block, export, and erase (two-key, with a seven-day cooling-off; the copy is atUsers.dc.html:823). Every action except message needs a typed reason and writes an audit row. - 6 account statuses (active, pending, suspended, blocked, deactivated, erased), kept separate from lifecycle, which is derived from the last meaningful action, never a login, in the bands new, engaged, slipping, dormant and churned. "Stuck" means signing in and doing nothing.
- Permissions come from the demo toggle:
perms()returnsusers.read,users.write,users.suspendandusers.impersonate(:935) and never reads the signed-in role (D3). - Demo states: Default, Empty search, Loading, Error, Read only; Light and Dark.
users-core.js is not in the saved pull, so the action list and the status and lifecycle vocabulary are the plan's enumeration from the session; the registry row agrees in prose (SCREENS.md:412).
3.5 Overview (Overview.dc.html)
- A header (stamp, Refresh, Focus mode), an error banner, loading skeletons.
- Seven bands: 6 vitals (MRR, workspaces, active cards, scans per month, sponsor revenue, WhatsApp delivery); Act on these first; Everything needing attention (filters: everything, act now, dated, watch); Operations map (one tile per desk); Waiting on you (approval counts); Shared dependencies (4 health rows); Already handled (the activity feed).
- It computes nothing. Every figure comes from
ops-signals.jssnapshot(), an aggregate over 13 desk cores. It is role-filtered: a row whose desk the role cannot open is absent, with an "N more sit outside the X role" note. - Demo states: Default, All clear, Loading, Error, Read only; Light and Dark; Comfortable and Compact.
The artboard is not in the saved pull. The registry rows (SCREENS.md:316, :415) confirm the seven bands, the six vitals, the four shared dependencies, Focus mode, the states, and that the screen computes nothing; they speak of "opening thirteen desks in turn". The import count of 13 is the plan's.
3.6 Leads & CRM (Leads.dc.html, leads-core.js)
This is the platform sales CRM: QR setu's own Ops and Sales pipeline of prospective merchants. It is not the merchant's own enquiries feature.
- 8 views (
Leads.dc.html:1274-1277): Overview (the command view: an in-app period picker and an owner filter; Act on these first; momentum tiles; acquisition by day; funnel health with per-stage stall thresholds; the lifecycle band; the revival pool and campaign readiness; source win rates; owner load), Today, Call list (urgency first, tie-broken by worth, each row closes in place), All leads (bulk actions), Pipeline, Team (load, missed SLAs, first-reply median, win rate), Segments and Fields (the custom-field registry). - The model (
leads-core.js):- 8 stages (
STAGES,:38-47), 3 of them terminal (TERMINAL,:55); Won is "Signed up and card published" (:44); - an append-only stage-event log;
- 8 lost reasons with a
revivableflag (LOST_REASONS,:637-646), whererefusedsuppresses the contact; - stall thresholds per stage (
STALL_DAYS,:650); - 7 call outcomes (
CALL_OUTCOMES,:58), and an outcome sheet that writes the activity, the transition, the contact stamp and the next date in one action; - urgency (
slaOf,:752) kept separate from worth (scoreOf,:726); - a contact policy (
contactPolicy,:780-792): a weekly cap, a refusal quiet period, suppression; - assignment rules with a dry run (
planAssignandrunAssign,:844-847), by specialism and area; - duplicate detection by phone (
duplicatesOf,:286); - the phone masked by default, and every reveal audited;
- a field registry (
FIELDS,:61): types from text to url,showIf, an industry scope,mappable(:27-32).
- 8 stages (
- Its own 5 roles (
ROLES,:918-924): Platform Admin, Sales Manager, Sales Executive, Marketing and Operations, over 11 CRM actions (view create edit delete export manage assign send fields segments team) and a record scope ofownorall. A Sales Executive sees only their own leads, filtered at the source (scoped,:928-930). The comment above says the roles mirror "thecrmpermission already declared in RBAC" (:913-917); RBAC'scrmrow holds 6 actions (D13). - The architecture rule: a Lead references a Contact, and consent lives only on the Contact. A lead never carries consent. Messages and consent cross into
contacts.js(:267-273). - Demo props: the theme; the role (5); access Full, Read only or No access.
4 · Design gaps
Each row needs a design correction or an owner decision. Evidence is design-read unless marked. The Row column is the tracker id that carries it; the round-1 prompt uses the same ids.
| # | Finding | Evidence | Needs | Row |
|---|---|---|---|---|
| G1 | No standalone sign-in page with nothing rendered behind it, a return to the desk after sign-in, light and dark, at 1440, 1024 and 720. The plan named it /admin/sign-in; under B1 its path belongs to the admin origin (ADR-0034) | signInModal() is an overlay (admin-shell.js:805-887) | design: a page, not a modal over a desk | QRS-1405 |
| G2 | No first sign-in or invite acceptance. The copy says credentials "are sent to you", which means emailing a password. There is no "set your password" screen and no link-expired or already-used state. The same screen serves an admin-initiated reset | :827, :844 | design; Q7: an invite or reset link, never an emailed password | QRS-1405 |
| G3 | No server outcomes: wrong email or password (one generic message, no account enumeration); account deactivated; a valid login with no active role assignment (expired, or a non-staff account); too many attempts or temporarily locked; network or service error; the submitting state | none drawn; the deny screen assumes a role exists | design | QRS-1405 |
| G4 | No session states: session expired (sign in again, return to the same desk); signed out because the password changed elsewhere or the role was revoked mid-session | none drawn | design | QRS-1405 |
| No forced password change on first sign-in (not dismissable: no Cancel, no Esc). Superseded by Q7: with a set-password link the person sets their own first password, and no temporary password exists to force a change from | A5 is dismissable by design (:1008-1009) | none | QRS-1405 | |
| A2 | Sign-in checks "at least six characters" against a ten-character policy | :872 against PW_RULES (:907-912) | design: one rule everywhere | QRS-1405 |
| D1 | The staff account lifecycle is not designed. Sign-in says accounts are "created in Access control" and passwords are reset there, but Access control has no invite or create, send-credentials, reset, deactivate or offboard flow. "Assign role" takes a free-text name with no email, so it cannot identify a principal | shell :827; in RBAC.dc.html the words invite, reset, deactivate and offboard occur 0 times each (grep, 2026-09-28); the modal :385-402 | design | QRS-1406 |
| No MFA in the design. Not a gap: the owner declined MFA on 2026-08-26 as an accepted risk (operator proposal §11 D2), and reconfirmed it on 2026-09-28 for cost. Not re-argued | none | QRS-899 | ||
| D3 | The role models disagree. The shell has 7 roles with desk lists; Access control has 10 roles with module × action grants; Users hardcodes its permissions from the demo toggle and never reads the role. Example: the Operations Manager opens Users, Leads and Moderation in the shell, while its Access control grants hold no crm and no moderation | shell ROLES (:152-164); SYSROLES (:508-519); Users.dc.html:935 | design: one model | QRS-1407 |
| D4 | No users module in the Access control catalogue, so users.* cannot be granted anywhere; Overview view is the only overlap | MODULES (:476-496), 19 rows | design | QRS-1407 |
| D5 | Platform staff access is mixed with merchant and organization access. Scopes include org and workspace; the External Partner (agency) and Support Agent roles are scoped per workspace; assignment targets include a merchant's workspace, "Chai & Charcha". CLAUDE.md §2: an enterprise admin is not a platform admin | SCOPES (:497); SYSROLES; SEED_ASSIGN (:523 onward) | decided (Q4), then design | QRS-1408 |
| D6 | The Overview depends on 13 desks, and the MVP builds three of them. Nearly every vital, attention row and map tile belongs to an unbuilt desk, and there is no "desk not built yet" state. R-05 requires availability false to read "not ready yet", never absent and never a lie. An existing pattern fits: capabilities.js already holds operator-surface capabilities (role templates and contracts; SCREENS.md names them platform.capability.roleTemplates and platform.capability.contracts) whose absent state is a reserved panel naming the store, with no disabled control and no date. The correction extends that ledger to per-desk stores and designs the Overview's reserved tiles and vitals | ops-signals.js imports; capabilities.js | design (the MVP Overview) | QRS-1409 |
| D7 | Users' links and actions route into out-of-scope desks: message to Communications, plan to Subscriptions, transfer to Workspaces, chain links to Templates, Leads and Billing, Segments to Communications. There is no presentation for a door whose desk is unavailable | users-core.js chain() and ACTIONS[].desk | design: the same state as D6 | QRS-1409 |
| D8 | Two-key erase has no approver surface. "Waiting on a second approver" is shown, but no queue exists where the second approver acts; Approvals holds access requests only | Users.dc.html:823, :1096; RBAC.dc.html:536 | design; round 1 reserves erase, so none is needed yet | QRS-1412 |
| D9 | "Reset password or OTP" does not fit the platform's credentials. Merchants and consumers sign in by WhatsApp OTP and hold no password (ADR-0032: the phone is a credential) | users-core.js ACTIONS.reset | design copy and a decision; round 1 leaves it out of this release | QRS-1412 |
| D10 | Impersonation's user-facing half is not designed: the banner on every screen, the notice to the user, the page-open log. They live in the merchant and consumer apps | the ACTIONS.impersonate note | superseded: no impersonated session is built, so this half is not needed. The read-only support view that replaces it gets its own later design round (§8.6); not in round 1 | QRS-1410 (superseded) |
| D11 | The read-only banner says "read-only Ops role" on Users and the Overview, but the read-only role is Auditor in the shell | Users.dc.html:1381; the Overview's read-only banner | design copy | QRS-1412 |
| D12 | Assignment dates are free-text inputs, which conflicts with the in-app picker rule used elsewhere ("never a browser date input") | RBAC.dc.html:398 | design | QRS-1412 |
| D13 | A fourth role model. Leads' 5 roles, 11 CRM actions and the own record scope do not exist in Access control: its crm row has 6 actions (no assign, send, fields, segments or team), and its scopes (platform, org, workspace) cannot say "own records" | leads-core.js:913-930; the crm row of MODULES | design, folded into D3 | QRS-1407 |
| D14 | Lead owners are a hardcoded name list (OWNERS), not staff principals. With Access control they must be operators who hold a CRM role | leads-core.js:57 | design | QRS-1407 |
| D15 | Import is a claim, not a design. The button raises a toast, "Lead import reuses the audience importer", which belongs to Communications, out of scope | Leads.dc.html:1244 | design: an import flow inside Leads | QRS-1411 |
| D16 | Sending, consent and segments cross into Communications. "Send WhatsApp", "Ask for consent", selections and segment handoffs go to the Communications desk and contacts.js, and the contact policy reads a message log that lives there | leads-core.js:267-273, :780-792 | decided (Q6: pipeline only), then the reserved state | QRS-1409 |
| D17 | "Won" means signed up and card published, but no link from a lead to the user or workspace it produced is designed, and Users counts "unconverted leads" as a population | STAGES won (:44); users-core.js POPULATIONS.lead | design and a decision; I8: a manual, confirmed, audited link | QRS-1411 |
| D18 | Leads' CRM policies (quiet days, the weekly cap and five more) live in controls.js, edited in Mission Control, which is out of scope | CTL.value('crm.*'), e.g. leads-core.js:784, :787 | editing reserved; the seeded values shown read-only | QRS-1409 |
Two older rows bear on the same designs. QRS-874: the Leads design hardcodes 12 industry labels against 14 database keys, so industry options must come from the industries keys. QRS-668: the merchant pipeline has 6 stages; the admin pipeline's 8 are a different pipeline, kept as designed and stored as keys.
5 · Frontend reality
Provenance: the plan's frontend assessment, with its citations re-read on 2026-09-28 (repo-read).
5.1 What exists, in apps/web
- The documented stack puts the panel in
apps/webat/admin/*(ADR-0011:38,:189; ADR-0028:48, with subdomains ruled out at:85-86). B1 and the owner's decision replace this (§8.2): the panel becomes its own workspace,apps/admin, on its own origin. apps/web/src/tiers/admin/holds two READMEs and nothing else. There is no/adminroute, and the:slugcatch-all swallows any undeclared root (apps/web/src/app/routes.ts:79-91,:105).- The web session lives in
localStorage(apps/web/src/app/supabase.browser.ts:40-88). There is no@supabase/ssrand no cookie, so a loader cannot see the user, and the documented gate ("the loader plusrequireAdminin the Edge Function",architecture/tiers.md:87-88; "two checks in two loaders", ADR-0011:38) cannot be built on it. This was the plan's session blocker. B1 removes the need, because the admin app serves no SSR data, and it is also why a same-origin panel was unsafe. - Reusable: the Worker and SSR pipeline; the noindex and no-store
headers()pattern;theme.ts(system, light and dark, withadminalready allowlisted,apps/web/src/app/theme.ts:53); the tokens through the Tailwind preset; theButton,TextField,Select,Iconandcnprimitives;authService;featuresService.entitlementSource; and the data-driven grouped nav ofapps/web/src/tiers/merchant/features/console/ConsoleShell.tsxas the model for an admin shell. - Missing: the primitives Table, Dialog, Popover or Menu, Tabs, Checkbox, Badge, Toast, Command, Pagination and a DatePicker; a density mechanism;
@qrsetu/observability, which is not a dependency ofapps/web(apps/web/package.json:25-30; QRS-1421); i18n in console copy (the merchant console uses literals); an admin seam or schemas inpackages/dataandpackages/schemas; a tier-boundary lint (QRS-842); and any test that boots the server (vitest runs on node withrenderToStaticMarkup).
5.2 What apps/admin changes
The owner's decision (§8.2) turns most of §5.1 into inputs rather than blockers:
apps/adminis a client-rendered React app with no SSR, served as Workers Static Assets. It sharespackages/*and never importsapps/web, and a lint boundary enforces that. The/adminhalf of QRS-842 becomes that workspace boundary.- It needs no SSR data and no
@supabase/ssr, because admin data is served only through operator-checked RPCs and Edge Functions (B1, I3). The plan's cookie-session task is retracted. - ADR-0034 decides whether
apps/web/src/uiis extracted into a shared web-primitives package or admin gets its own set. Either way the missing primitives are design-first (ADR-0015): pull the design-system components before building them. @qrsetu/observabilityis wired intoapps/admin(I9, QRS-1421), and every admin string comes from@qrsetu/i18n, with no literals and no dashes.- A
packages/dataadmin seam, bound to Supabase in the barrel and never a stub (QRS-636), with itspackages/schemas.
5.3 Ledgers and reviews
design-system/screen-conformance.json:31-32deliberately excludes the Admin Panel;check:desktop-paritycovers merchant and marketplace only;design-system/parity-contracts/holds noadmin-*file (0, counted 2026-09-28). Admin screens need their own ledger entries (QRS-1420).- RBAC and Overview were never design-reviewed (
design-system/screen-reviews/admin-panel/index.md:148-150, "Not yet reviewed"), and neither was Users, which is newer than that list. The Subscriptions review recorded "RBAC 0% built, role scope missing from schema and from RBAC.dc.html" (QRS-863).
5.4 Gates that bite
check:namingN2 bans directories namedtemplates,cards,plans,primitivesorarchetypesunderapps/,packages/,supabase/,tools/andtooling/(tools/check-naming.js:103-109,:299-312). A feature directory for "Plans & limits" must be named for its kind.check:readmesneeds a README per feature directory, andcheck:docs-impactapplies.check:paritydoes not scanapps/web, so it will not seeapps/admineither unless it is extended (expected, unverified).
6 · Backend reality
Provenance: read from the repository's migrations (116 live at the time of the read) and its 15 Edge Functions, not queried on live Dev; re-read citations carry a file:line. On 2026-09-28 the tree holds 118 migrations: the two added that day (20260928120000, 20260928130000) are biodata and touch nothing here.
6.1 There is no admin identity at all
- No operator, role, permission or assignment table exists in any migration.
requireAdmin()(supabase/functions/_shared/auth.ts:85-99) queries the droppedprofiles.role, so it admits nobody (QRS-889, P0). There is no custom access-token hook. - The design for fixing it exists as a draft, the platform operator control plane proposal:
- a
platform_operatorstable,is_platform_operator()andrecord_audit_event(); - operators are never workspace members (INV-1) and never referenced by RLS (INV-3);
- rejected: a JWT claim (revocation lag), a
userscolumn, and RLS by role; - owner decisions: D1, the guard lives in
provision_merchant_workspace(); D2, no MFA; - open: C1 (QRS-900), an operator can onboard as a merchant; C5 (QRS-902), the
opsandoperatorslugs are not reserved.
- a
- ADR-0035 amends the proposal and ADR-0006. The superseded task list proposed graduating to ADR-0006's shape (
platform_roles,platform_role_grants,platform_role_assignments, time-bounded), because the design's time-bound assignments fire the proposal's own trigger for that shape. ADR-0035 (Proposed) adopts those three tables besideplatform_operators.
6.2 Reusable
audit_log: append-only, withreason,beforeandafter, andactor_kindalready allowssupport. It has one writer today,set_my_primary_context(20260817230500_v2_set_my_primary_context.sql:104), and that writer is deliberately best-effort: a failed audit insert is swallowed as a warning (:103-112). That is exactly the pattern B6 forbids for operator actions.idempotency_keyswith_shared/idempotency.ts(claim, complete, fail, rate limit).users.status(active, suspended, deleted; no later migration widens it) andworkspaces.statuswith suspension provenance;setu_cards.statusincludessuspended.feature_grants.source = 'platform_admin'.communication_messages.statusfor WhatsApp delivery.- The service-role Auth-admin pattern in
manage-accountdelete_account(thesoft_delete_accountRPC and a ban throughupdateUserById). - The branded Auth email templates, including
invite.htmlandreset-password.html(supabase/email-templates/rendered/).
6.3 Broken or missing, relevant to the MVP
- Global sign-out by user id has no working primitive.
auth.admin.signOuttakes a JWT, not a user id, andmanage-account/index.ts:138passesuser.id. The plan expected that call to fail silently every time; spike (b) measured that there is no sign-out by user id at all (QRS-1413), and that a ban deletes no session (§9.3). users.statusis enforced by nothing (QRS-870). Two functions read it:get_my_contextmerely projects it (20260904102614_v2_account_soft_delete.sql:86-90), andsoft_delete_accountchecks it for idempotency (20260904150210_v2_soft_delete_cascade.sql:70).requireAuthreads no status at all (_shared/auth.ts:72). Suspension cascades to nothing (QRS-892).blocked,deactivatedanderaseddo not exist.- There is no activity stream (QRS-891), so the design's lifecycle has no source. Only
auth.users.last_sign_in_atexists, and only the service role can reach it. - Verification and KYC are not built (ADR-0026). Nor are impersonation, export jobs, erasure jobs, a transfer-ownership RPC or subscription history (QRS-893).
6.4 What could back the Overview
| Signals | |
|---|---|
| Backed | workspace count, published-card share, WhatsApp delivery, registered users; the audit feed, once there are writers |
| Partial | MRR (nominal, from platform_plans × active subscriptions, never "collected"); reports: qr_reports has no resolution column (20260909170000_v2_qr_reports_and_verify_lookups.sql; QRS-1426); template status: the WhatsApp webhook records message_template_status_update events and dead-letters them, "no template-sync handler shipped yet" (supabase/functions/whatsapp-webhook/index.ts:185-203; QRS-1425) |
| Absent | scans, sponsors, dunning, rewards, verification; and leads until increment 3 builds them |
6.5 Leads & CRM backend
Provenance: the plan's Explore report of 2026-09-28; the items re-read for this page carry a file:line.
- Nothing exists for the platform sales CRM: no ADR, table, Edge Function or code. The only statement was
communications/leads-and-crm.md:169-172: the platform pipeline is "a different subject", "a distinct workspace or a distinct table, not the same rows with a flag". Q8 now decides it: a dedicated platform table (QRS-1427, ADR-0037). - Absent tables:
leads,parties(the proposed contact registry, QRS-873),communication_consents, suppression, campaigns, inbound messages. The WhatsApp migration refuses the consent, campaign and inbound tables by name (20260901120000_v2_communications_whatsapp.sql:22-32). - The platform cannot send a non-OTP WhatsApp today.
- The only approved templates are three
qrsetu_otptemplates. - There is no consent or suppression check anywhere, no weekly cap and no quiet hours.
communication_messages.correlation_typehas nolead: it allowsorder,payment,campaign,reminderandaccount(20260901120000_v2_communications_whatsapp.sql:228), and no later migration alters it.- The webhook handles no inbound messages, so no STOP or START.
- The shared number
+91 92703 73367serves Dev and Prod and every merchant, so a quality drop hurts everyone. - ADR-0029 and ADR-0030 are Proposed.
- The only approved templates are three
- Reusable:
_shared/communication.ts(the sender, approved-template resolution, the category check, the counts ledger, enqueue);communication_messagesand its events;rate_limits;audit_log; theindustrieskeys (QRS-874). - Conflicts: the merchant's 6 stages (QRS-668) against the admin design's 8; "consent keyed on the phone" (
communications/leads-and-crm.md:74-76) against the design's "consent on the Contact" (the same intent; the key has to be settled); andrate_limitscites a_shared/rateLimit.tsthat does not exist (absent on 2026-09-28; QRS-1423).
6.6 Conventions every admin object follows
- Definer RPCs with
SET search_path = public,REVOKEfromPUBLIC,anonandauthenticated, explicit grants,COMMENT ON, and no SQLSTATE class 40 or 08 for a refusal; expand-contract migrations. - Writes go through an Edge Function with a required idempotency key and a
config.tomlentry. - A change to
users,workspacesoraudit_logneeds an approved proposal (check:arch-proposal). - ADR-0031's schema vocabulary is closed, so admin objects stay in
public, as the proposal chose.
6.7 Documentation contradictions, corrected in place (QRS-1422)
- "
audit_loghas 0 writers" (§6.2 shows one). - "
role_keyis read by nothing" and "has no CHECK". - ADR-0006's
role_keyvalues. - The operator proposal's foreign-key action.
- CLAUDE.md §5 still lists
meetingsas a schema; ADR-0031 A1 withdrew it on 2026-09-04.
6.8 ZeptoMail, for the invite and reset links (spike (a))
Q7 sends staff credentials as Supabase Auth invite and recovery links through the project's SMTP relay. The repository's record is stale: ZeptoMail "Current state (2026-08-01)" (integrations/zeptomail.md:85) says the relay pointed at the wrong provider, and QRS-285 is still open.
- Owner-reported, the ZeptoMail console on 2026-09-28:
qrsetu.comVerified; DKIM31152624._domainkeyVerified (Default); the CNAMEbounce-zemtocluster89.zeptomail.inVerified; the sender addressno-reply@qrsetu.com; Sent 0 and Delivered 0 for 13 to 28 September. - Measured the same day with
nslookup: SPF isv=spf1 include:_spf.mail.hostinger.com ~all, with no ZeptoMail include; DMARC isp=none. - Reading. The domain side is done. SPF on the root is probably irrelevant, because ZeptoMail's return path is
bounce-zem.qrsetu.comand DKIMd=qrsetu.comaligns for DMARC (expected, unverified). - Unverified: where Supabase Auth's SMTP host points on Dev. The 2026-08-01 defect was
smtp.hostinger.com, and no evidence shows it has moved; a Sent counter of 0 fits either answer. QRS-285 is not fixed until spike (a) passes.
7 · Principal Architect review
Verdict, 2026-09-28: not production-ready as written; ready after the corrections below. The owner accepted the corrections (§8.2) and the revised plan (§9).
7.1 Measured for the review
- Writing RPCs granted to
authenticated: the plan's heuristic scan found 2 inpublic(set_my_primary_context,set_my_display_name) and did not parsebiodata.*. Later scans found more. A crude regex run while publishing this page found 4, addingset_my_prefs(20260907121000_v2_consumer_prefs_write.sql:179) andmark_notifications_read(20260907120000_v2_consumer_read_state_consolidated.sql:208). Two independent scans of every latest definition, recorded in ADR-0036 and in agreement, find 51 functions granted toauthenticated, 5 of which write: those four andacknowledge_merchant_payment_alerts. Every one of these is repo-read. The list that guards holds must be read frompg_procon Dev before increment 2 relies on it; with no impersonated session to guard (§8.6), holds are now its only user. - The card purge runs inline (
_shared/cardCache.ts, best-effort by construction), not through the undrained outbox (QRS-885). jwt_expiry = 3600(supabase/config.toml:51; the repo's local-stack config, not the live projects).- The repo does not record the project's JWT signing mode. Spike (d) has since measured it: Dev signs with ES256 (§9.3).
- CI is billing-blocked (QRS-790), and the owner's authorisation for the manual web deploy expires on 2026-09-30 (
apps/web/scripts/deploy-manual.mjs:61).
7.2 Blockers, fixed before any code
| # | Blocker | Evidence | What must ship with the fix | Lands in |
|---|---|---|---|---|
| B1 | Same-origin admin. /admin on qrsetu.com shares an origin with merchant-authored public content and with the merchant console, whose session lives in localStorage. With MFA declined, one XSS anywhere on the origin can take a staff session, which means read access to every tenant. ADR-0028 (Proposed) ruled out subdomains without this reason | ADR-0028 :48, :85-86; supabase.browser.ts:40-88; QRS-899 | admin.qrsetu.com as its own Worker, a strict CSP, frame-ancestors 'none', no third-party script. With admin data served only through operator-checked RPCs and EFs, the admin app needs no SSR data and no @supabase/ssr | decided (§8.2); ADR-0034; QRS-1416 |
| B2 | "Suspend" enforces nothing today. A ban stops sign-in and refresh only. After revocation, Edge Functions refuse at once, while PostgREST honours the last access token until it expires. users.status is enforced by nothing (only projected and idempotency-checked), the RLS helpers ignore status, workspace suspension is ignored, and global sign-out by user id has no working primitive | jwt_expiry = 3600; QRS-870, QRS-892; _shared/auth.ts:72; manage-account/index.ts:138; spike (b), measured (§9.3) | enforcement in requireAuth (every EF), in the definer RPCs' my_* helpers and in the public card render; an account-holds model, an append-only hold record (kind, subject, reason, by, prior state, lifted_at and lifted_by, and a partial unique index for one active hold per kind per subject) instead of churning a status enum; and the revocation primitive spike (b) measured: a service-role definer that deletes auth.sessions | increment 2; ADR-0036; QRS-1415, QRS-1413, QRS-1433; the companion design round; the owner's hold decision (§8.6) |
| B3 | Staff principals collide with the tenant plane. handle_new_user gives every auth user a business principal and a public slug; an operator can complete merchant onboarding; signup is open; and because email is unique, a staff member whose merchant account already uses the same email cannot be invited | 20260905233000_v2_every_account_has_an_address.sql:187-234 (QRS-1078); C1 (QRS-900); enable_signup = true (supabase/config.toml:52) | the provisioning path for staff, designed and measured on the local stack before the first operator migration. Spike (c) measured it: a service-role pre-registration row that the trigger consumes; user_metadata is client-writable and never grants | spike (c); increment 1; ADR-0035; QRS-1414 |
| B4 | Four role models, and the wrong order. The superseded task list ran the backend seeding in parallel with the design round that settles the roles | D3, D13 | the design round first; then one permission registry (modules × actions plus a record scope) as code in @qrsetu/domain; then generated database seeds, with a gate that the registry, the seeds and the UI agree | Stage 1, then increment 1; ADR-0035; QRS-1407 |
| B5 | Impersonation is unproven and privacy-heavy. Minting a user JWT needs the project's signing key, which under Supabase's asymmetric signing keys is not exportable (expected, unverified when raised). Viewing a merchant's chats exposes their consumers (ADR-0032). Measured since: Dev signs with ES256, so tokens cannot be minted, and the magic link gives a full, writable session | the repo does not record the signing mode; spike (d), measured (§9.3) | a measured spike and an ADR, with a read-only support view rendered inside admin from operator RPCs as the fallback. Decided by the owner on 2026-09-28 (§8.6): the support view, with its own design round. A real impersonated session is not built | ADR-0038 (reserved); increment 5, shipped last; QRS-1418; QRS-1410 superseded |
| B6 | Audit atomicity. The action and its audit row commit in one transaction, inside the definer RPC, never best-effort from the EF. An action that calls GoTrue or Cloudflare outside the database needs an intent row, then the external call, then a completion row, with idempotent re-drive, so a crash cannot leave a ban without an audit, or the reverse | the one audit writer today is best-effort (20260817230500_v2_set_my_primary_context.sql:103-112); the card purge is best-effort inline (_shared/cardCache.ts) | the atomic audit writer | increment 1, then every action after it |
7.3 Important
| # | Finding | What it changes |
|---|---|---|
| I1 | Password policy. An EF-side rule is bypassable: a signed-in client can call updateUser({ password }) directly | enforce it project-wide in Supabase Auth (minimum length, character classes, the leaked-password check if the plan tier allows). Merchants are almost all OTP; measure how many use a password before deciding. Decided (§8.2); QRS-1417 |
| I2 | Separation of duties | nobody grants a role to themselves or edits their own assignment; only a Super Admin grants Super Admin or Platform Administrator; a database invariant of at least one active Super Admin; a break-glass runbook (service role, two people, audited); the first operator seeded per environment by runbook, never by a migration carrying a real email |
| I3 | Where the permission check lives | every admin RPC checks operator_can(auth.uid(), …) itself, called with the caller's JWT. The service role is used only for the Auth admin API and Cloudflare; a read over serviceClient would skip the check |
| I4 | PII | Users exposes the phone and email of everyone: apply Leads' server-side masking and audited reveal to Users too; add a read-audit for record opens; make export a permissioned, audited action, with CSV formula injection neutralised; set DPDP retention for leads and audit |
| I5 | Directory scale | a query across auth.users cannot be indexed by us. Build a directory read model now, maintained by triggers on users and on auth.users insert and update (the precedent is handle_new_user), with keyset paging, a trigram name prefix, an exact E.164 match and approximate counts. Spike (e) measured the case for it at 100k users: page 1 in 0.10 ms with the read model against 20.3 ms without, and an exact email in 0.05 ms against 51.6 ms |
| I6 | No scheduler (QRS-885) | time-bound assignments are evaluated at check time (starts_at <= now() < ends_at). "Auto-expiry" is derived, never a job |
| I7 | Concurrency | optimistic concurrency (expected_version) on stage moves, holds and assignments; an idempotency key per action; bulk actions capped per call and chunked with a visible progress state, because there are no background jobs |
| I8 | ADR-0032 | auto-linking a Won lead to a user by phone uses the phone as an identifier. Make it a manual, confirmed, audited link only (QRS-1411) |
| I9 | Observability | @qrsetu/observability in the admin app (QRS-1421); security events for staff sign-in failures, holds, bulk actions, reveals per operator and impersonation (now the support view, §8.6); alerts that are push-based, because no workflow runs on a schedule |
| I10 | Tests | an authorization matrix generated from the registry (every role × module × action, allow and deny) at both the pgTAP and the EF level; non-operator and anon refusal; built-Worker E2E for every sign-in state; a local-stack load check of the directory at 100k synthetic users; everything runnable locally, because CI is blocked |
| I11 | DoD parity | "admin is web-only" is recorded as the ADR-0011-sanctioned exception in each feature README, because the DoD says "no exception path" (ADR-0034 words it as the sanctioned scope: ADR-0011's surface table gives admin no native app, :189). A mobile impersonation banner would have triggered native builds; it is superseded (§8.6), and the generic held-account state in the merchant and consumer apps now does |
| I12 | Auth configuration is a production change class | the redirect-URL allowlist, SMTP, the password policy, the email templates and the session time-box each need a Change Record (R-06) |
7.4 Scale risks
- The admin aggregates run on the tenant primary: put them behind a read model (ADR-0010) before volume.
audit_loggrowth: plan monthly partitioning and retention.- 500k leads need predicate bulk jobs, which need a scheduler decision (QRS-885).
- A future organization or enterprise RBAC must never reuse the operator tables.
8 · Decisions
8.1 The owner's answers to the plan (Q1 to Q8)
| # | Decision | Amended by |
|---|---|---|
| Q1 | Staff sign-in: work email and password, as designed. Supabase Auth, accounts created by a Super Admin, a forced change at first sign-in. No MFA (D2 stands), confirmed for cost: end users stay on WhatsApp OTP, where every sign-in is a charged Meta authentication template, and staff sign in several times a day, so their sign-in must send nothing. Email and password sends no message per sign-in; ZeptoMail is used only for the invite and a rare reset; the refresh token keeps a staff session alive through the day. It is the same Supabase Auth and auth.users, with email already an enabled provider, and it never touches the Send SMS hook. Access is decided by the operator row, never by the ability to sign in: a stranger's email and password account gets nothing. "No self-serve reset" means the UI does not offer one; a public-API reset still only reaches the account's own inbox. Staff creation must not trigger the merchant slug and principal path | its "cookie session scoped to /admin" is replaced by the admin origin (B1, §8.2); its "staff password rule in our set-password EF" is replaced by the project-wide policy (I1, §8.2); its forced change at first sign-in is superseded by Q7: with a set-password link the person sets their own first password, and no temporary password exists (G5) |
| Q2 | RBAC depth: system roles seeded as data (a read-only matrix), the permissions catalogue, permanent and time-bound assignments, and the audit log. Custom role editing and Approvals are reserved | seeds generated from one registry (B4); expiry evaluated at check time (I6) |
| Q3 | User actions: suspend and reactivate, block and unblock, sign out all devices, view as user. The rest are reserved | holds with enforcement (B2), both kinds a ban plus a session delete (§8.6); view as user became the read-only support view (B5, §8.6) |
| Q4 | Scope: platform staff only. Organization and workspace scopes and External Partner go to the enterprise track | |
| Scope addition: Leads & CRM is in the MVP | ||
| Q5 | Sign-in design: validated as partly designed (§3.2, G1 to G5). A standalone screen and its states go into design round 1 as the first priority | G5 is superseded by Q7 |
| Q6 | CRM sending: pipeline only. Sending, consent and segments are reserved. A refused lost reason is recorded on the lead and flagged for the future suppression list, so nothing is lost when Communications lands | |
| Q7 | Staff credentials: a set-password link (invite and reset), sent automatically through ZeptoMail, which the owner reports is configured and approved. The person sets their own first password through the link, so no temporary password exists and no forced change follows (it supersedes Q1's forced change, G5) | delivery is confirmed only by spike (a) |
| Q8 | Leads store: a dedicated platform table. It never shares rows with merchant leads, and it links to the user or workspace it produced when Won | the Won link is manual, confirmed and audited (I8) |
8.2 The owner's decisions on the review
- The admin origin is
admin.qrsetu.com(B1 accepted; QRS-1416; ADR-0034).- Structure. A new workspace,
apps/admin: a client-rendered React app with no SSR, served as Workers Static Assets. It sharespackages/*, never importsapps/web, and a lint boundary enforces that. ADR-0034 decides whetherapps/web/src/uibecomes a shared web-primitives package or admin gets its own set. ADR-0011's "admin shares the merchant console's codebase" is amended to "shares the monorepo and its packages". ADR-0028 is amended to name the subdomain, with the XSS reason. - One-time setup. Three admin Workers and hostnames (prod
admin.qrsetu.com; the dev and UAT names besidedevvanduatt, set in ADR-0034). Custom domains bound by hand in the Cloudflare dashboard, asapps/webdoes, because@cloudflare/vite-plugindrops theenv.*routes (apps/web/wrangler.jsonc:11-35). Deploys with an explicit--name. The Supabase Auth redirect-URL allowlist on both projects. Per-environment variables. A deploy path, which inheritsdeploy-web's record of no successful run and the CI block, so the pipeline fix is a dependency of increment 1 reaching Prod. - Headers. CSP
default-src 'self', withconnect-srclimited to Supabase and observability;frame-ancestors 'none';noindex;no-storeon HTML. - Edge Function CORS is already
*(supabase/functions/_shared/cors.ts:7). It stays, since the bearer token is the boundary; the new admin EFs may be restricted to the admin origins. - Cloudflare Access in front of
admin.qrsetu.comis required, no longer optional, because the static handbook cannot be protected by the app's sign-in (§8.4). It also compensates, at the network edge, for the declined MFA. Access gates the origin's pages and assets; the app calls Supabase directly (connect-src), so the API's gate staysoperator_caninside every RPC and EF (I3).
- Structure. A new workspace,
- The password policy is project-wide in Supabase Auth (I1; QRS-1417). First measure how many non-staff accounts hold a password, then apply it through a Change Record on both projects.
- View as user: spike first, ship last (B5; QRS-1418). Spike (d) plus ADR-D, and increment 5 only if both pass. Otherwise a design round for the in-admin support view. Spike (d) did not pass, and the owner chose the support view (§8.6).
8.3 Worker topology
Two Workers per environment, six in total: the three that exist (.github/workflows/deploy-web.yml:106-122) and three new ones.
| Environment | Existing (apps/web) | New (apps/admin: the panel and /handbook/) |
|---|---|---|
| Dev | qrsetu-web-dev → devv.qrsetu.com | qrsetu-admin-dev → admin-devv.qrsetu.com (the name is set in ADR-0034) |
| UAT | qrsetu-web-uat → uatt.qrsetu.com | qrsetu-admin-uat → admin-uatt.qrsetu.com |
| Prod | qrsetu-web-prod → qrsetu.com | qrsetu-admin-prod → admin.qrsetu.com |
- Why separate: independent deploys and blast radius; different security headers (a strict CSP for admin, crawlable and shareable for cards); no admin code in the public bundle (ADR-0011's "separate deploy"); and Cloudflare Access applies per hostname.
- Single-level hostnames (
admin-devv, notadmin.devv), so Universal SSL's*.qrsetu.comcovers them. Deeper custom-domain certificates are unverified, and avoided. - Cost: billed per request, not per Worker, and static assets do not invoke the Worker. Expected to be negligible for internal traffic; not measured on the account.
- Deploy: an explicit
--nameper Worker; custom domains bound in the dashboard (a runbook step); the deploy job added besidedeploy-web, with the same CI dependency (QRS-790). Dev and UAT both run against the Dev Supabase project (deploy-web.yml:113-116), so both admin hostnames belong on Dev's redirect allowlist.
8.4 The Operations Handbook
What it is. A documentation portal inside the admin panel for the QR setu operations team, built the way the dev portal is (VitePress, Mermaid and the brand theme). Written only by Claude, read-only for operations. It covers how each feature is used, its end-to-end flows and how each admin desk works, across the product the team supports (merchant, consumer, Setu Card, biodata and so on) as well as the desks. Tracker: QRS-1419.
Hosting. admin.qrsetu.com stays the one internal origin: the panel at / and the handbook at /handbook/, served by the same admin Worker's static assets, in one deploy.
- The handbook is static HTML, so the app's sign-in cannot protect it:
/handbook/*would be fetchable directly, and so would docs compiled into the JS bundle. - Cloudflare Access on
admin.qrsetu.comis therefore required. One policy gates the panel and the handbook, allowing@qrsetu.comidentities; the plan cites Access as free up to 50 users (not checked against the account). - Access sits in front of the Supabase email and password sign-in, which is still what grants panel access.
Placement.
apps/admin/handbook/: its own VitePress config and markdown, building into the admin app's static output under/handbook/and deployed with the admin Worker. It is its own package, as the dev portal is, so VitePress (Vue) never enters the React app's dependency graph.- One shared theme:
documentation/portal/.vitepress/themeis extracted into one package (for exampletooling/vitepress-theme) used by the dev portal and the handbook, so there is one brand and no copy-paste drift. The Mermaid font rule (theconfig.mjsheader) moves with it. - Not
documentation/portal/, the engineering source of truth: a different audience, with architecture, security and credentials content. Not a newdocumentation/operations/: everything underdocumentation/exceptportal/is the archive (CLAUDE.md §7). - Sensitive runbooks stay in the dev portal: break-glass, the staff-compromise response, first-operator seeding. The handbook links to none of them.
Staying current, because the risk of any handbook is that it rots:
- A handbook page is part of the Definition of Done for any feature the operations team touches.
- A gate,
check:handbook: every admin desk and every registered ops-facing feature has a page with alastVerifiedcommit. A change to a feature's route or EF without a handbook edit fails it, unless the commit carries aHandbook-Impact:trailer (the model ofcheck:docs-impact). It prints a summary line on every run. - The handbook builds in the admin build. Broken links fail the build:
ignoreDeadLinksis off (the dev portal has it on,.vitepress/config.mjs:34). - Copy follows the product rules: no em or en dash, and no internal ids (no
QRS-ids, table or RPC names). - The shell gets a Handbook nav entry and a per-desk "How this desk works" link. Both are designed elements, so they are in design round 1.
Increments. Increment 1 ships the skeleton (the structure, the theme package, the Access gate, the build, check:handbook) and the sign-in and Access control pages. Each later increment ships its desk's pages in the same change. The product-flow pages (merchant onboarding, card, orders, consumer biodata and so on) are a backlog written alongside, one flow per page, each with a Mermaid diagram of the end-to-end journey.
8.5 The ADRs
All Proposed until the owner accepts them; ADR-0034 to ADR-0037 were drafted in parallel with this page.
| The plan's letter | ADR | Subject |
|---|---|---|
| A | ADR-0034 | the admin origin, its session and the edge gate (Cloudflare Access); the web-primitives question; the dev and UAT hostnames |
| B | ADR-0035 | operator identity, the permission registry and the admin data plane, amending the operator proposal and ADR-0006 |
| C | ADR-0036 | account holds and their enforcement |
| D | ADR-0038 (reserved, not yet written) | the read-only support view inside admin, which replaces impersonation after spike (d) (§8.6; QRS-1418) |
| E | ADR-0037 | the platform leads operations pipeline |
The review also amends two existing decisions: ADR-0011 (admin shares the monorepo and its packages, not the merchant console's codebase) and ADR-0028 (the admin subdomain, with the XSS reason).
8.6 The owner's decisions on the spike results
Both taken on 2026-09-28, on the measured results of spikes (b) to (e) (§9.3). This is Stop 1 for those four spikes; spike (a) is still open.
- Holds. Suspend and Block are both a ban plus a session delete, the primitive spike (b) measured. The account holder sees one generic state, with no reason and no kind. A workspace hold is enforced in our own layer, not by Supabase Auth. Consequences:
- The design's suspend copy, "The owner is told the reason you type here" (
users-core.jsACTIONS, as quoted in Account holds, round 1), is reversed; that prompt already designs the one generic state. The reason a staff member types stays on QR setu's record and the audit log. - An unban revives any session a ban left behind (QRS-1433), so a hold deletes the sessions rather than trusting the ban alone.
- Carried by ADR-0036 and QRS-1415.
- The design's suspend copy, "The owner is told the reason you type here" (
- The support view. View as user becomes a read-only support view inside the admin panel, rendered from operator RPCs, with its own design round and its own ADR, ADR-0038 (reserved), and it ships last. A real impersonated session is not built: Dev signs with ES256, so a user token cannot be minted, and the magic link gives a full, writable session (spike (d)). The user-facing banner, notice and page-open log of D10 (QRS-1410) are superseded.
9 · The revised plan
9.1 Stages, increments and stops
The increments are shown in the plan's order. Increment 4 reads what increments 1 to 3 produce. Increment 5, the read-only support view that replaced View as user after spike (d) (§8.6), ships last, after a design round of its own. A design round and its approval also open every increment (§10).
9.2 Stage 0: decisions and ADRs, before any code
- The owner decides the admin origin (B1), the project-wide password policy (I1), the impersonation gate (B5), and delivery in independently shippable increments. All four were decided on 2026-09-28 (§8).
- The ADRs are drafted, all Proposed until the owner accepts them (§8.5).
- The documentation contradictions are corrected in place (QRS-1422), the tracker rows are filed (QRS-1404 to QRS-1428, then QRS-1431 to QRS-1435 from the spikes), this assessment is published, and the design-gap loop is saved as a standing memory.
9.3 Stage 0.5: spikes, measured on the local stack and Dev, each with pasted output
Four of the five were measured on 2026-09-28, and the owner decided on their results the same day (§8.6). Their pasted output is on the spike results page. Spike (a) is still pending: it waits for the owner's test inbox.
| Spike | Question | What it must show | Result, 2026-09-28 | Unblocks | Row |
|---|---|---|---|---|---|
| (a) ZeptoMail invite | Does a staff invite leave through ZeptoMail and authenticate? | Dev's Auth SMTP settings read host smtp.zeptomail.in and sender no-reply@qrsetu.com; one real staff invite to a test inbox moves ZeptoMail's Sent counter from 0 to 1; its headers show dkim=pass and DMARC pass | Pending the owner's test inbox | increment 1's invite and reset; closing QRS-285 | QRS-285 |
| (b) Revocation primitive | Can one user be signed out everywhere, now? | a ban plus session and refresh-token invalidation, after which an existing refresh token fails | Measured. There is no sign-out by user id (QRS-1413). A ban deletes no session, and an unban revives them (QRS-1433). The primitive is a service-role definer that deletes auth.sessions: Edge Functions then refuse at once, while PostgREST honours the last access token until it expires. The prototyped is_session_live() check costs about 30 µs a call | holds, sign out all devices, staff offboarding, the manage-account fix | QRS-1413, QRS-1415, QRS-1433 |
| (c) Staff principal creation | Can a staff account exist with no slug and no merchant path? | an invited staff account with no slug and no route into merchant onboarding; a stated answer for a staff email already held by a merchant account (B3) | Measured. Staff provisioning uses a service-role pre-registration row that the trigger consumes; user_metadata is client-writable and never grants | the first operator migration | QRS-1414 |
| (d) JWT signing mode and impersonation | Can a short-lived, read-only user session be minted at all? | the project's signing mode, read; minting proven or disproven; the writing RPCs granted to authenticated, measured (§7.1) | Measured. Dev signs with ES256, so tokens cannot be minted; the magic link gives a full, writable session. The owner chose the support view (§8.6) | the view-as-user decision; ADR-0038 | QRS-1418 |
| (e) Directory at 100k | Does the directory read model hold at scale? | the query plan at 100k synthetic users on the local stack, using keyset paging, the trigram name prefix and the exact E.164 match, with approximate counts (I5) | Measured. Build the read model: page 1 in 0.10 ms against 20.3 ms, and an exact email in 0.05 ms against 51.6 ms, at 100k users | increment 2 | QRS-1404 |
For spike (a), also check the Auth email rate limit (the plan cites 30 per hour as Supabase's default with custom SMTP) and measure first whether the invite and recovery templates are used by any merchant or consumer flow. If recovery is shared, a staff-specific copy needs generateLink plus our own ZeptoMail API send (QRS-076, not built), and that decision is made on the measurement. On a pass, QRS-285 closes with that evidence and integrations/zeptomail.md is corrected in place, with the correction logged.
9.4 Stage 1: design round 1
The prompt is Admin Panel MVP, round 1, to be sent by the owner, with its companion Account holds, round 1: what a held account looks like to the public and to its holder, which is B2's user-facing half.
- In the round: sign-in first (G1 to G4 and A2; G5 is superseded by Q7), then D1 to D18 as §4's Needs column says; the handbook entry points (§8.4); industry options from the
industrieskeys (QRS-874); the admin pipeline's 8 stages kept as designed and distinct from the merchant's 6 (QRS-668). - Not in the round: View as user (D10), which spike (d) ruled out and the owner replaced with the read-only support view, designed in a later round of its own (§8.6); anything that sends a message (Q6); editing platform policies.
- The design-gap loop (§10) runs through this round and every later one.
9.5 The increments
Each increment runs the same pipeline:
Increment 1, "Staff access" (ADR-0034, ADR-0035)
- The
admin.qrsetu.comWorker, the shell, the CSP, a kill switch and observability. - Sign-in, set password, and sessions (a time-box and a sign-in audit). There is no forced first change: the invite link lets the person set their own first password, so G5 is superseded by Q7.
- The operator identity, the permission registry and the atomic audit writer (B4, B6).
- Access control: the system roles read-only, assignments, the audit log.
- The staff and assignments Edge Function: invite, resend invite, reset, deactivate, assign, revoke. It is idempotent and has a
config.tomlentry. Invite usesauth.admin.inviteUserByEmailwithredirectTopointing at the admin origin's set-password route; reset uses the recovery link (generateLinkof typerecovery, orresetPasswordForEmail) with the same redirect. The links are GoTrue's single-use, expiring tokens, delivered by Supabase Auth's SMTP through ZeptoMail with the branded templates, their copy adapted for staff. No password is ever emailed or seen by the inviting admin. Every send and resend writes an audit row and is rate-limited. - The migration: the operator tables and the check
operator_can(auth.uid(), module, action)(I3; the old task list wroteoperator_can(module, action)besideis_platform_operator()), all definer, revoked and commented, seeded from the registry; theopsandoperatorslugs reserved (QRS-902;adminis already reserved,20260808110000_v2_reserved_slugs.sql:263); the guard inprovision_merchant_workspace()(C1, QRS-900).requireAdmin()is retired, andrequireOperator(module, action)in_shared/auth.tsruns before any query as the cheap early refusal; the RPC's own check stays the authority. - Runbooks: seed the first operator, break-glass, offboarding, and suspected staff compromise (revoke all operator sessions).
- The handbook skeleton, with its sign-in and Access control pages (§8.4).
- The
apps/admindev server on port 5180 (8080, 8081, 4173, 4180, 8090 and 8091 are taken). The owner's side-by-side review page (D:\DevCache\design-mirror\admin-review.htmlon:8091, outside the repo, set up 2026-09-28, QRS-1404) already points its build pane there. Each desk's build path is set in that page when its desk is built.
Increment 2, "Users" (ADR-0036)
- The directory read model (I5; spike (e) measured the case for it), the record, Pulse.
- Masking and an audited reveal (I4).
- Account holds with enforcement (B2), as the owner decided on the spike results (§8.6): Suspend and Block are both a ban plus a session delete; the account holder sees one generic state, with no reason and no kind; a workspace hold is enforced in our own layer.
- The hold record; enforcement in
requireAuth, in the definer RPCs'my_*helpers and in the public card render; the card unpublished and purged on write (ADR-0027); every external step with its intent and completion rows (B6). - Edge Functions refuse a deleted session at once, but PostgREST honours the last access token until it expires, so the
my_*helpers need a live-session check. Spike (b) prototyped one,is_session_live()(doesauth.sessionsstill hold the token'ssession_id), at about 30 µs a call, then rolled it back.
- The hold record; enforcement in
- Sign out all devices, on the primitive spike (b) measured (a service-role definer that deletes
auth.sessions), and the fix tomanage-account/index.ts:138(QRS-1413). An unban revives any session a ban left (QRS-1433), so lifting a hold never relies on the ban having cleared them. last_meaningful_action_atstays optional, as the owner left it, and is not in this increment's list. Lifecycle, Cohorts, Churn and Interventions stay reserved until an activity source exists (QRS-891).
Increment 3, "Leads & CRM" (ADR-0037)
- The pipeline scope of §9.6, with I7 (optimistic concurrency on stage moves and assignments; bulk actions capped and chunked) and I8 (a manual, confirmed, audited Won link).
- An operator-only schema in
public(ADR-0031's closed vocabulary), revoked fromanonandauthenticatedand read through definer RPCs:- tables:
platform_leads(a stage key,owner_user_idpointing at an operator, anindustry_keyFK, source, priority,next_action_at,stage_since, the lost reason,fields jsonbwith a GIN index,converted_user_idandconverted_workspace_id); append-onlyplatform_lead_stage_eventsandplatform_lead_activities(call, note, reveal, assignment, import); a field-registry table; seeded CRM policy values; - RPCs: the list (keyset, exact phone and name prefix), the record, Today and the call list, the overview aggregates, the team;
- EF actions: create, edit, a stage move with an outcome, assign, planned against run assignment, reveal (audited), import (validate, then commit), mark Won with a link. Idempotent, and permission-checked through
operator_can('crm', …)plus theownscope.
- tables:
- No consent, no sending and no contact table (Q6). A
refusedlost reason is recorded on the lead and flagged for the future suppression list. - The names pass
check:naming, and the table stays apart from any future merchantleads(Q8). They are the superseded task list's proposal, and ADR-0037 (Proposed) adopts them, naming the field registryplatform_lead_fields.
Increment 4, "Overview": the backed signals from increments 1 to 3 (§6.4) and a reserved tile, naming its store, for everything else (D6). The snapshot reports absent capabilities as absent. Before volume, the aggregates move behind a read model (ADR-0010).
Increment 5, "Support view" (ADR-0038, reserved), shipped last: a read-only support view inside the admin panel, rendered from operator RPCs, after a design round of its own (the owner's decision, §8.6).
- A real impersonated session is not built. Spike (d) measured that Dev signs with ES256, so a user token cannot be minted, and that the magic link gives a full, writable session.
- Withdrawn with it: the mechanism the plan had recommended (an Edge Function minting a 25-minute JWT with an
impersonationclaim, animpersonation_sessionstable,requireAuthrefusing that token on a mutation) and D10's user-facing banner, notice and page-open log (QRS-1410). - The view stays inside admin, so, unlike the plan's increment 5, it does not cross into the merchant and consumer shells. What it may show, including whether a merchant's chats appear at all (B5: they expose the merchant's consumers, ADR-0032), is ADR-0038's to decide.
The three stops. After Stage 0.5 (the owner decides on the spike results); after each design round (the owner approves); before any Prod change (the owner asks). Nothing is pushed or deployed without the owner's ask.
9.6 The MVP boundary per desk
The rule: a design element ships when its store exists or is built in this MVP. Otherwise it renders the design's reserved (availability) state, recorded as a blocked row in the parity contract with its reason. It is never silently omitted (R-04) and never faked (R-07).
| Desk | Ships in the MVP | Reserved: store named, visible, never faked | Amended by |
|---|---|---|---|
| Shell | Staff sign-in (email and password), set password (no forced first change: G5 is superseded by Q7), change password, sign out, the grouped nav filtered by role, the deny screen, the drawer below 1024, light and dark, the command palette over desks, the Handbook entry and the per-desk link | the reminders and notifications popovers (no store); unbuilt desks' nav rows say "not ready yet" (R-05 availability) | B1, §8.4 |
| Access control | System roles as seeded data with a read-only matrix; the permissions catalogue; permanent and time-bound assignments; the audit log (read and export); the staff lifecycle once designed (D1) | creating, cloning, inheriting or editing a custom role; bulk retire; the Approvals queue (Q2) | B4, I2, I6 |
| Users | The Directory (search, facets, sorts, paging); the Record (identity, status, the chain's counts from real tables, the timeline from audit_log and sign-ins); Pulse (registered, the population split, paying from subscriptions); masking and audited reveal; the actions suspend and reactivate, block and unblock, sign out all devices, each reason-gated and audited, on the three-axis "not available here" panel; the read-only support view that replaced view as user, in increment 5 (§8.6) | lifecycle, Cohorts, Churn, Interventions and "stuck" until an activity source exists; Segments; message, plan, transfer, export, erase, verify, reset | B2, B5, I4, I5 |
| Leads & CRM | A dedicated platform leads table (never merchant rows). The Overview (the aggregates that have sources), Today, the Call list with the outcome sheet, the list with bulk stage and assign, Pipeline, Team, Fields, duplicates by phone, the masked phone with audited reveal, lost reasons with refused recorded, the stage-event log, assignment rules with a dry run, a CSV import designed inside Leads (D15), and Won linked to the account it produced (D17). Roles come from Access control (D13) | Send WhatsApp and Ask for consent; Segments to campaigns; campaign readiness; reachable for marketing (Q6: they need consent, suppression, inbound STOP and approved templates). CRM policy editing (D18: the seeded values are shown; editing lives in Mission Control) | I7, I8 |
| Overview | The header; the backed vitals (workspaces, published cards, WhatsApp delivery, users); attention rows from Users, Access control and Leads; operations-map tiles for the built desks; the feed from audit_log; the loading, error and all-clear states | every other desk's tile and vital, as a reserved tile naming its store (D6) | the read-model scale risk (§7.4) |
9.7 The superseded task ids, re-homed
The superseded task list is not reproduced; older notes that cite its ids resolve here. This mapping is derived by this page from the two versions of the plan.
| Old id | What it was | Now |
|---|---|---|
| P0.1 to P0.4 | the owner's answers; the proposal amendment; the ADR-0011 and ADR-0028 amendment; the doc corrections and tracker rows | Stage 0 (§9.2) |
| P1 | the design corrections | Stage 1 (§9.4) |
| T-B0 | confirming ZeptoMail delivery | spike (a) |
| T-B1 | operator tables, operator_can, the seeds, the slugs, the provisioning guard | increment 1, after spike (c); the seeds generated from the registry (B4) |
| T-B2 | record_audit_event() and the audit read RPC | increment 1, atomic (B6) |
| T-B3 | requireOperator(module, action) | increment 1 |
| T-B4 | the staff and assignments EF, including "expire" | increment 1; expiry is evaluated at check time, never a job (I6) |
| T-B5 | the user actions, by widening users.status | increment 2, as account holds with enforcement (B2) |
| T-B6 | the read RPCs, as a definer join over auth.users | increment 2, as a read model (I5); the Overview snapshot in increment 4 |
| T-B7 | the pgTAP and Deno tests | every increment, plus the generated authorization matrix (I10) |
| T-B8 | view as user | replaced: increment 5 is the read-only support view (§8.6); no impersonated session is built |
| T-B9 | the Leads & CRM backend | increment 3 |
| T-W1 | the @supabase/ssr cookie session scoped to /admin | retracted (B1) |
| T-W2 to T-W6 | the route family, the primitives, the shell, observability and i18n, the admin seam | increment 1, in apps/admin |
| S1 to S5 | the screens: sign-in, Access control, Users, Leads, Overview, view as user | S1 and S2 in increment 1, S3 in 2, S3b in 3, S4 in 4; S5 became the support view in increment 5 (§8.6) |
| P5 | validation | each increment's parity contract, the admin ledger (QRS-1420), Change Records |
10 · The design-gap loop, a standing rule
Set by the owner on 2026-09-28 (the plan's §6a).
Design round 1 is only the first correction round. Any gap or disconnect found at any later point, whether while building, while wiring the backend or during validation, goes back through Claude Design before its flow is implemented. Nothing is implemented on an assumption, and no workaround UI is invented in the meantime.
What counts as a gap
- a design row that the parity contract cannot pass;
- a designed state, action or number with no store behind it and no designed reserved state;
- two artboards, or an artboard and its
platform/*.jsmodel, that disagree (roles, names, vocabulary); - a flow that dead-ends or hands off to something that does not exist ("handled elsewhere" is a claim until the elsewhere exists);
- a designed control the backend refuses (for example, a status the schema cannot hold);
- copy that contradicts platform rules (credentials, dashes, privacy reassurance).
Not a gap; these never go to Claude Design
- Device layout (keyboard, insets, system navigation) is ours to fix in code.
- An architecture-gated question is resolved first as a
QRS-###or an ADR, and only its settled outcome is sent (screen coverage mandate: "Never send an architecture-gated question to Claude Design").
The loop, per batch of gaps
- Stop that flow only. Independent parts of the same screen continue. Record the gap: a tracker row (max+1); the parity-contract row marked
blockedwith the reason "awaiting design round N"; never left blank. - Batch by screen. All the gaps found on one screen go into one round rather than a stream of small prompts.
- Write the prompt as a portal page,
design-system/admin-panel-round-<N>-prompt.md. It restates the product frame every round, pasting the existing-product-context block; namesprototype/admin-panel/and theplatform/*.jsmodules it must extend; names the nav it plugs into and says "you are extending an app, not building one"; and lists each gap with its evidence and the settled decision behind it. It is registered inconfig.mjsand in the mandate's round table, and it must passnpm run check:design-prompt. - The owner passes the prompt to Claude Design. Claude never sends it and never edits the design project.
- When the owner says it was passed, fetch fresh:
list_files, thenget_filefor the returned files, into the scratchpad; diff against the previous pull after CRLF normalisation; re-enumerate the returned artboard against the round's gap list, with a verdict per gap: resolved · still open · new gap. A remaining gap opens round N+1. It is never quietly accepted. - The owner approves the returned design. Then the parity-contract rows move from
blockedto their verdicts, the design review page is updated, and only then is that flow implemented.
Every screen follows this order: pull fresh → design:spec → parity contract → gap check → (loop until approved) → implement → validate.
11 · Verification
How "done" is proven, increment by increment. Every claim is produced by a command whose output is pasted (R-03), and every gate is read for four things: its summary line, its exit code, its test count, and whether it printed anything at all (CLAUDE.md §6).
Gates, run locally, because CI is billing-blocked (QRS-790):
npm run lint,npm run type-checkandnpm test, reading the counts;npm run test:db(pgTAP) andnpm run test:ef;check:sql,check:naming,check:readmes,check:i18n-keys,check:docs-impact,check:arch-proposal,check:design-parity,check:claimsandcheck:context;- the gates this programme adds: the registry, seeds and UI agreement gate (B4); the authorization matrix generated from the registry, every role × module × action allowed and denied at the pgTAP and the EF level, with non-operator and anon refusal (I10); the admin ledger (QRS-1420);
check:handbook(§8.4).
The built admin Worker. The run-web driver drives apps/web; apps/admin needs its equivalent. It checks:
- with no session, the origin shows the sign-in page (on a deployed hostname, Cloudflare Access comes first);
- a signed-in principal with no operator row sees the no-active-role state (G3), never a desk;
- each role sees only its desks, and a direct URL to a forbidden desk shows the deny screen;
- every sign-in state of G1 to G4 renders (I10; G5 is superseded by Q7);
- the headers, read off the served HTML and never off config: the CSP,
frame-ancestors 'none',noindex, andno-storeon HTML (a static asset skips the Worker, so where the headers come from is itself measured); - light and dark at 1440, 1024 and 720.
End to end, on Dev only:
- invite a staff member: the ZeptoMail email arrives (headers
dkim=passand DMARC pass; the Sent counter moves), the link opens the set-password screen, a second use of the link is refused and an expired link shows its state; then the first sign-in with the password the person set (there is no forced change: G5 is superseded by Q7); - a time-bound assignment stops granting at its
ends_at, with no job running (I6); - separation of duties: a self-grant is refused, the last Super Admin cannot be removed, and only a Super Admin grants Super Admin or Platform Administrator (I2);
- a CRM flow: a lead through its stages with every event logged; a Sales Executive sees only their own leads (the count equals the list); a reveal writes an activity row; an import dry run, then a commit; Won linked to a test workspace through the confirmed manual link (I8);
- put a hold on a test merchant: sign-in is refused and every session is deleted; an access token issued before the hold is refused at once by an EF, and by the
my_*helpers once they check the session is live (B2, spike (b)); the account holder sees the one generic held state, with no reason and no kind (§8.6); the card is unpublished and its public URL purged; the audit row carries the reason and was committed in the same transaction as the hold (B6); lifting the hold reverses each of those, and an old refresh token still fails after the lift (QRS-1433); - sign out all devices, after which an existing refresh token fails (spike (b));
- a failure injected between an external call's intent row and its completion row is re-driven to completion (B6).
Before any backend change: run npx supabase projects list and read the names before any db push or functions deploy (prefer npm run sb -- dev). Nothing touches Prod. The Auth settings (the redirect allowlist, SMTP, the password policy, the templates, the session time-box) are a production change class, each with its own Change Record (I12); nothing measures auth settings (R-06), so each is verified by reading the live setting.
Parity. "Admin is web-only" is recorded as the ADR-0011-sanctioned exception in each feature README (I11). The generic held-account state (increment 2, §8.6) is shown in the merchant and consumer apps, so it takes the Android, iOS and PWA device pass. The support view (increment 5) stays inside admin, and the mobile impersonation banner is superseded (QRS-1410).
12 · What this page did not verify, and what publishing found
12.1 Not verified here
- Nothing on live Dev or Prod was queried for this page. Backend statements are repo-read unless marked measured, and every expected, unverified item stays so until a spike or a gate reads the live object.
- The design files not in the saved pull (§3):
Overview.dc.html,users-core.js,segments.js,desk-records.js,ops-signals.js,capabilities.js,leads.spec.md,controls.jsandcontacts.js. Claims resting on them are the plan's enumeration, cross-checked against theSCREENS.mdprose only. - The spike results: spikes (b) to (e) are summarised in §9.3 from the spike results page. This page checked its one-line figures against that page (ES256; 0.10 against 20.3 ms and 0.05 against 51.6 ms; about 30 µs for the liveness check) but did not re-run any spike. Spike (a) has no result yet.
- The ADR drafts: ADR-0034 to ADR-0037 were written in parallel with this page and were read only for the points §7.1 and §12.2 cite; ADR-0034 was re-read on 2026-09-28 after its D2, D4, D5 and D7 changed. ADR-0038 is reserved and not yet written. Everywhere else, where this page says what an ADR decides, it restates the plan, not the ADR.
- Cloudflare facts that the plan states and nobody checked against the account: the Access free tier of 50 users, Universal SSL covering single-level hostnames, per-request billing.
12.2 Found while publishing, not yet reviewed by the owner
- The built-desk count. D6's "the MVP builds 2" and the old boundary's "tiles for the two built desks" were written before Leads joined the scope that day. The MVP builds three of the Overview's source desks (Access control, Users, Leads) plus the Overview itself; the round-1 prompt already says four desks.
- Writing RPCs granted to
authenticated: 5, not 2. The plan's scan found 2; ADR-0036's two agreeing scans find 5 of 51 (§7.1). The guard list for holds must come frompg_procon Dev, not from any scan of the repo; with the support view (§8.6) there is no impersonated session left to guard. adminis already a reserved slug (20260808110000_v2_reserved_slugs.sql:263). QRS-902 is aboutopsandoperator; the old task list reservedadmintoo.- Paths written for the same-origin layout. Resolved in ADR-0034 D7 (QRS-1428). G1's
/admin/sign-in, the set-password redirect<origin>/admin/set-passwordand the old verification's "/adminunauthenticated" predate B1. ADR-0034 D7 now sets the paths inside the admin origin (/for the panel,/sign-in,/set-passwordas the single invite and reset target,/handbook/; desk paths with the first desk) and withdraws the old two. The same section coversqrsetu.com/admin:routes.tsdeclares no admin route, and the:slugloader 301s any undeclared root to/<slug>/setu-cardwith no reserved-slug check (apps/web/src/app/routes/setu-card-redirect.tsx:21-26), the mechanismroutes.ts:79-91records for/marketplaceand/merchant. So todayqrsetu.com/adminwould 301 to/admin/setu-card(repo-read, not requested live). D7 requires/adminto be declared, recommending a single 301 tohttps://admin.qrsetu.com/, with a plain 404 as the alternative, in increment 1. - The one existing audit writer is best-effort by design (
20260817230500_v2_set_my_primary_context.sql:103-112). B6 applies to operator actions, and ADR-0035 D9 forbids copying this precedent into them. - The session time-box. Resolved in ADR-0034 D5 and ADR-0035 D7. Increment 1 lists a session time-box and I12 lists it as an Auth setting. Supabase's session time-box and inactivity timeout are project-wide settings (expected, unverified), so, like the password policy (I1), a staff time-box set there would bind merchants and consumers too. ADR-0034 D5 therefore enforces it where only staff pass: the operator check (ADR-0035 D7) refuses a session older than the staff limit, and a merchant or consumer session is unaffected.
- Getting a test through Cloudflare Access. Resolved in ADR-0034 D4: automated tests pass Access with a service token, admitted by a policy rule on the Dev and UAT applications only; Prod admits people only. The login method for people stays a setup-runbook decision.
- Custom domains by hand. Resolved in ADR-0034 D2, to be measured in increment 1.
apps/webbinds its domains in the dashboard because@cloudflare/vite-plugindrops theenv.*routes (apps/web/wrangler.jsonc:11-35). ADR-0034 D2 now says that reason belongs to the SSR build:apps/adminmay not need the plugin, in which case its domains become code (expected, unverified), and increment 1 measures it on Dev by reading the Worker's bound domains back. - Citation drift, remeasured here. The sign-in modal is
admin-shell.js:805-887(the plan said 805 to 880); the change-password modal:907-1012(913 to 1016); the RBAC date inputs:398, as placeholders (395 to 396); "Assign role":385-402(386 to 402); the two-key copyUsers.dc.html:823(820). The branded templates are undersupabase/email-templates/rendered/.check:namingN2 bans five names in five trees, not three underapps/. This page uses the remeasured values. (requireAdmin()is at_shared/auth.ts:85-99today, as the plan said.) - Q1's session and password notes are superseded by B1 and I1, and its forced first change by Q7 (G5); §8.1 marks all three.
13 · Cross-references
Tracker rows filed with this programme
| Row | Subject | Where on this page |
|---|---|---|
| QRS-1404 | The programme: Admin Panel MVP | §1, §9 |
| QRS-1405 | Staff sign-in design (G1 to G4 and A2; G5 is superseded by Q7) | §3.2, §4 |
| QRS-1406 | The staff account lifecycle (D1) | §4 |
| QRS-1407 | One role model (D3, D4, D13, D14) | §4, B4 |
| QRS-1408 | Access control is platform staff only (D5) | §4, Q4 |
| QRS-1409 | The availability ("not ready yet") state (D6, D7, D16, D18) | §4, §9.6 |
| QRS-1410 | Impersonation, the user-facing design (D10); superseded by the support view | §4, B5, §8.6 |
| QRS-1411 | The Leads import and the Won link (D15, D17) | §4, I8 |
| QRS-1412 | Copy and controls (D8, D9, D11, D12) | §4 |
| QRS-1413 | Global sign-out by user id has no working primitive (measured by spike (b)) | §6.3, B2, §9.3 |
| QRS-1414 | Staff principals collide with the tenant plane | §3.2, B3 |
| QRS-1415 | Account holds and their enforcement | B2, increment 2 |
| QRS-1416 | The admin origin decision | B1, §8.2 |
| QRS-1417 | The password policy, project-wide | I1, §8.2 |
| QRS-1418 | The impersonation spike (d): measured, a user token cannot be minted | B5, §9.3, §8.6 |
| QRS-1419 | The Operations Handbook | §8.4 |
| QRS-1420 | Admin screens sit outside every ledger | §5.3 |
| QRS-1421 | @qrsetu/observability is not a dependency of apps/web | §5.1, I9 |
| QRS-1422 | The documentation corrections batch | §6.7 |
| QRS-1423 | rate_limits cites a _shared/rateLimit.ts that does not exist | §6.5 |
| QRS-1424 | A false claim on the landing page | not discussed here; the row holds the evidence |
| QRS-1425 | WhatsApp template status events are recorded and never acted on | §6.4 |
| QRS-1426 | The report tables carry no resolution | §6.4 |
| QRS-1427 | The platform leads store decision | §6.5, Q8 |
| QRS-1428 | qrsetu.com/admin falls into the :slug catch-all | §12.2 item 4 (ADR-0034 D7) |
| QRS-1431 | citext under search_path = public | the spike results page |
| QRS-1432 | A session-less token; the legacy HS256 secret | the spike results page |
| QRS-1433 | An unban revives sessions | §9.3 (b), §8.6, increment 2 |
| QRS-1434 | Redirect, link and invite behaviours | the spike results page |
| QRS-1435 | Local-stack auth gaps | the spike results page |
QRS-1429 and QRS-1430 were filed the same day for other work (tracker rendering, and biodata-read).
Older rows this page relies on: QRS-076 (the ZeptoMail send EF, not built), QRS-285 (the Auth SMTP relay, open), QRS-636 (a stub seam is not wired), QRS-668 (the merchant pipeline's stages), QRS-790 (the CI billing block), QRS-842 (the /admin and /org boundary lint), QRS-863 (RBAC 0% built), QRS-870 (users.status enforced by nothing), QRS-873 (the contact registry), QRS-874 (hardcoded industry labels), QRS-885 (no scheduler), QRS-889 (requireAdmin()), QRS-891 (no activity stream), QRS-892 (suspension enforces nothing), QRS-893 (no subscription history), QRS-899 (MFA declined), QRS-900 (C1), QRS-902 (C5), QRS-1078 (every account has an address).
Decisions
- New, all Proposed: ADR-0034 · ADR-0035 · ADR-0036 · ADR-0037.
- Reserved, not yet written: ADR-0038, the read-only support view (§8.6).
- Amended: ADR-0006 RBAC and authorization · ADR-0011 frontend platform · ADR-0028 URL namespace · the operator control plane proposal.
- Relied on: ADR-0010 analytics read model · ADR-0015 design governance · ADR-0026 verification · ADR-0027 purge-on-write · ADR-0029 WhatsApp · ADR-0030 tenant communication identity · ADR-0031 domain schemas · ADR-0032 chat and platform identity.
Pages
- Admin Panel MVP, round 1 and its companion Account holds, round 1.
- Admin Panel MVP spike results: the pasted output behind §9.3.
- Admin Portal backend readiness assessment, the 2026-08-26 predecessor.
- Screen coverage mandate · ZeptoMail · Leads and CRM · User lifecycle · Tier system · Current architecture state · Operating rules · Quality gates · Release process · Tracker.