Skip to content

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:

LabelMeans
design-readread from the design files pulled on 2026-09-28, with file:line
repo-readread from a repository file (code, migration, portal page), with file:line. It says what the repo declares, not what a live project runs
measureda 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-reportedstated or shown by the owner
expected, unverifiedan 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 ​

StatusApproved 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 scopeStaff 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 scopeThe 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 servesQR 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).
EnvironmentsDev (qr-setu-dev) only. Nothing reaches Prod without the owner's ask and a Change Record (R-02, R-06).
The owner's sequencelatest 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.

  1. 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).
  2. 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.
  3. 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.
  4. Q4 Access control covers platform staff only. Organization and workspace scopes and the External Partner role move to the enterprise track.
  5. Scope addition Leads & CRM is in the MVP.
  6. Q5 Sign-in is only partly designed. A standalone screen and its states go into design round 1, first.
  7. Q6 Leads & CRM is a pipeline only. Sending, consent and segments are reserved.
  8. Q7 Staff credentials arrive as a set-password link (invite and reset), sent automatically through ZeptoMail. Nobody is ever emailed a password.
  9. Q8 Platform leads get a dedicated table that never shares rows with merchant leads, linked to the account it produced when Won.
  10. Origin The panel lives at admin.qrsetu.com, as a new client-rendered workspace apps/admin, behind Cloudflare Access, which is required.
  11. Password policy Enforced project-wide in Supabase Auth, after measuring how many non-staff accounts hold a password.
  12. View as user Spike first, ship last. The spike ruled out a real impersonated session (item 18).
  13. Topology Two Workers per environment, six in total.
  14. Operations Handbook A read-only documentation site for the operations team at admin.qrsetu.com/handbook/, written only by Claude.
  15. Delivery Independently shippable increments, with three stops for the owner.
  16. Standing rule Every design gap found at any later point goes back through Claude Design before its flow is built (§10).
  17. 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.
  18. 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.

FileSizeWhat it isRe-read for this page
admin-panel/admin-shell.js80 KB, 1,073 linesthe shell every desk loadsyes
admin-panel/RBAC.dc.html103 KB, 815 linesAccess controlyes
admin-panel/Users.dc.html134 KB, 1,691 linesUsersyes
admin-panel/Leads.dc.html190 KB, 2,146 linesLeads & CRMyes
platform/leads-core.js60 KB, 930 linesthe lead model: stages, roles, contact policyyes
admin-panel/Overview.dc.htmlread inline in the sessionthe Overviewno: not in the saved pull; checked against SCREENS.md only
platform/users-core.js, segments.js, desk-records.js, ops-signals.js, capabilities.jsread in the sessionthe Users model and its action registry, saved views, the desks' records, the cross-desk aggregate, the capability ledgerno: not in the saved pull; checked against SCREENS.md only
admin-panel/leads.spec.md; platform/controls.js, contacts.jsthe Leads spec; the policies and contacts that leads-core.js imports (:19-20)no
SCREENS.md255 KBthe design registryyes, 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 on rbac), 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 in RBAC.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).

#ElementDesigned?Evidence
A1Credentials 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, autofocusyes:805-887; the explainer at :827
A2Client 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 controlyes: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)
A4A role picker, "Continue as"demo only:845 onward. Must not ship: the role comes from the assignment
A5Change 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
A6Profile menu: name, email, the role card, the Roles link, Change your password, Sign outyes:463-484. "Back to the prototype hub" (:479) is prototype-only
A7Deny screen: "This desk is not part of your role", with a way to the first allowed deskyes: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 (ACTIONS and ARISK, :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 placeholders 2026-07-18 and 2026-08-18 (:398).
  • Approvals: the queue for sensitive changes, seeded from desk-records.js ACCESS_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 kind as 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.js ACTIONS): 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 at Users.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() returns users.read, users.write, users.suspend and users.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.js snapshot(), 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 revivable flag (LOST_REASONS, :637-646), where refused suppresses 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 (planAssign and runAssign, :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).
  • 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 of own or all. A Sales Executive sees only their own leads, filtered at the source (scoped, :928-930). The comment above says the roles mirror "the crm permission already declared in RBAC" (:913-917); RBAC's crm row 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.

#FindingEvidenceNeedsRow
G1No 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 deskQRS-1405
G2No 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, :844design; Q7: an invite or reset link, never an emailed passwordQRS-1405
G3No 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 statenone drawn; the deny screen assumes a role existsdesignQRS-1405
G4No 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-sessionnone drawndesignQRS-1405
G5No 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 fromA5 is dismissable by design (:1008-1009)noneQRS-1405
A2Sign-in checks "at least six characters" against a ten-character policy:872 against PW_RULES (:907-912)design: one rule everywhereQRS-1405
D1The 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 principalshell :827; in RBAC.dc.html the words invite, reset, deactivate and offboard occur 0 times each (grep, 2026-09-28); the modal :385-402designQRS-1406
D2No 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-arguednoneQRS-899
D3The 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 moderationshell ROLES (:152-164); SYSROLES (:508-519); Users.dc.html:935design: one modelQRS-1407
D4No users module in the Access control catalogue, so users.* cannot be granted anywhere; Overview view is the only overlapMODULES (:476-496), 19 rowsdesignQRS-1407
D5Platform 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 adminSCOPES (:497); SYSROLES; SEED_ASSIGN (:523 onward)decided (Q4), then designQRS-1408
D6The 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 vitalsops-signals.js imports; capabilities.jsdesign (the MVP Overview)QRS-1409
D7Users' 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 unavailableusers-core.js chain() and ACTIONS[].deskdesign: the same state as D6QRS-1409
D8Two-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 onlyUsers.dc.html:823, :1096; RBAC.dc.html:536design; round 1 reserves erase, so none is needed yetQRS-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.resetdesign copy and a decision; round 1 leaves it out of this releaseQRS-1412
D10Impersonation'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 appsthe ACTIONS.impersonate notesuperseded: 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 1QRS-1410 (superseded)
D11The read-only banner says "read-only Ops role" on Users and the Overview, but the read-only role is Auditor in the shellUsers.dc.html:1381; the Overview's read-only bannerdesign copyQRS-1412
D12Assignment dates are free-text inputs, which conflicts with the in-app picker rule used elsewhere ("never a browser date input")RBAC.dc.html:398designQRS-1412
D13A 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 MODULESdesign, folded into D3QRS-1407
D14Lead owners are a hardcoded name list (OWNERS), not staff principals. With Access control they must be operators who hold a CRM roleleads-core.js:57designQRS-1407
D15Import is a claim, not a design. The button raises a toast, "Lead import reuses the audience importer", which belongs to Communications, out of scopeLeads.dc.html:1244design: an import flow inside LeadsQRS-1411
D16Sending, 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 thereleads-core.js:267-273, :780-792decided (Q6: pipeline only), then the reserved stateQRS-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 populationSTAGES won (:44); users-core.js POPULATIONS.leaddesign and a decision; I8: a manual, confirmed, audited linkQRS-1411
D18Leads' CRM policies (quiet days, the weekly cap and five more) live in controls.js, edited in Mission Control, which is out of scopeCTL.value('crm.*'), e.g. leads-core.js:784, :787editing reserved; the seeded values shown read-onlyQRS-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/web at /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 /admin route, and the :slug catch-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/ssr and no cookie, so a loader cannot see the user, and the documented gate ("the loader plus requireAdmin in 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, with admin already allowlisted, apps/web/src/app/theme.ts:53); the tokens through the Tailwind preset; the Button, TextField, Select, Icon and cn primitives; authService; featuresService.entitlementSource; and the data-driven grouped nav of apps/web/src/tiers/merchant/features/console/ConsoleShell.tsx as 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 of apps/web (apps/web/package.json:25-30; QRS-1421); i18n in console copy (the merchant console uses literals); an admin seam or schemas in packages/data and packages/schemas; a tier-boundary lint (QRS-842); and any test that boots the server (vitest runs on node with renderToStaticMarkup).

5.2 What apps/admin changes ​

The owner's decision (§8.2) turns most of §5.1 into inputs rather than blockers:

  • apps/admin is a client-rendered React app with no SSR, served as Workers Static Assets. It shares packages/* and never imports apps/web, and a lint boundary enforces that. The /admin half 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/ui is 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/observability is wired into apps/admin (I9, QRS-1421), and every admin string comes from @qrsetu/i18n, with no literals and no dashes.
  • A packages/data admin seam, bound to Supabase in the barrel and never a stub (QRS-636), with its packages/schemas.

5.3 Ledgers and reviews ​

  • design-system/screen-conformance.json:31-32 deliberately excludes the Admin Panel; check:desktop-parity covers merchant and marketplace only; design-system/parity-contracts/ holds no admin-* 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:naming N2 bans directories named templates, cards, plans, primitives or archetypes under apps/, packages/, supabase/, tools/ and tooling/ (tools/check-naming.js:103-109, :299-312). A feature directory for "Plans & limits" must be named for its kind.
  • check:readmes needs a README per feature directory, and check:docs-impact applies.
  • check:parity does not scan apps/web, so it will not see apps/admin either 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 dropped profiles.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_operators table, is_platform_operator() and record_audit_event();
    • operators are never workspace members (INV-1) and never referenced by RLS (INV-3);
    • rejected: a JWT claim (revocation lag), a users column, 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 ops and operator slugs are not reserved.
  • 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 beside platform_operators.

6.2 Reusable ​

  • audit_log: append-only, with reason, before and after, and actor_kind already allows support. 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_keys with _shared/idempotency.ts (claim, complete, fail, rate limit).
  • users.status (active, suspended, deleted; no later migration widens it) and workspaces.status with suspension provenance; setu_cards.status includes suspended.
  • feature_grants.source = 'platform_admin'.
  • communication_messages.status for WhatsApp delivery.
  • The service-role Auth-admin pattern in manage-account delete_account (the soft_delete_account RPC and a ban through updateUserById).
  • The branded Auth email templates, including invite.html and reset-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.signOut takes a JWT, not a user id, and manage-account/index.ts:138 passes user.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.status is enforced by nothing (QRS-870). Two functions read it: get_my_context merely projects it (20260904102614_v2_account_soft_delete.sql:86-90), and soft_delete_account checks it for idempotency (20260904150210_v2_soft_delete_cascade.sql:70). requireAuth reads no status at all (_shared/auth.ts:72). Suspension cascades to nothing (QRS-892).
  • blocked, deactivated and erased do not exist.
  • There is no activity stream (QRS-891), so the design's lifecycle has no source. Only auth.users.last_sign_in_at exists, 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
Backedworkspace count, published-card share, WhatsApp delivery, registered users; the audit feed, once there are writers
PartialMRR (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)
Absentscans, 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_otp templates.
    • There is no consent or suppression check anywhere, no weekly cap and no quiet hours.
    • communication_messages.correlation_type has no lead: it allows order, payment, campaign, reminder and account (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 73367 serves Dev and Prod and every merchant, so a quality drop hurts everyone.
    • ADR-0029 and ADR-0030 are Proposed.
  • Reusable: _shared/communication.ts (the sender, approved-template resolution, the category check, the counts ledger, enqueue); communication_messages and its events; rate_limits; audit_log; the industries keys (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); and rate_limits cites a _shared/rateLimit.ts that does not exist (absent on 2026-09-28; QRS-1423).

6.6 Conventions every admin object follows ​

  • Definer RPCs with SET search_path = public, REVOKE from PUBLIC, anon and authenticated, 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.toml entry.
  • A change to users, workspaces or audit_log needs 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_log has 0 writers" (§6.2 shows one).
  • "role_key is read by nothing" and "has no CHECK".
  • ADR-0006's role_key values.
  • The operator proposal's foreign-key action.
  • CLAUDE.md §5 still lists meetings as a schema; ADR-0031 A1 withdrew it on 2026-09-04.

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.com Verified; DKIM 31152624._domainkey Verified (Default); the CNAME bounce-zem to cluster89.zeptomail.in Verified; the sender address no-reply@qrsetu.com; Sent 0 and Delivered 0 for 13 to 28 September.
  • Measured the same day with nslookup: SPF is v=spf1 include:_spf.mail.hostinger.com ~all, with no ZeptoMail include; DMARC is p=none.
  • Reading. The domain side is done. SPF on the root is probably irrelevant, because ZeptoMail's return path is bounce-zem.qrsetu.com and DKIM d=qrsetu.com aligns 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 in public (set_my_primary_context, set_my_display_name) and did not parse biodata.*. Later scans found more. A crude regex run while publishing this page found 4, adding set_my_prefs (20260907121000_v2_consumer_prefs_write.sql:179) and mark_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 to authenticated, 5 of which write: those four and acknowledge_merchant_payment_alerts. Every one of these is repo-read. The list that guards holds must be read from pg_proc on 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 ​

#BlockerEvidenceWhat must ship with the fixLands in
B1Same-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 reasonADR-0028 :48, :85-86; supabase.browser.ts:40-88; QRS-899admin.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/ssrdecided (§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 primitivejwt_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.sessionsincrement 2; ADR-0036; QRS-1415, QRS-1413, QRS-1433; the companion design round; the owner's hold decision (§8.6)
B3Staff 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 invited20260905233000_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 grantsspike (c); increment 1; ADR-0035; QRS-1414
B4Four role models, and the wrong order. The superseded task list ran the backend seeding in parallel with the design round that settles the rolesD3, D13the 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 agreeStage 1, then increment 1; ADR-0035; QRS-1407
B5Impersonation 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 sessionthe 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 builtADR-0038 (reserved); increment 5, shipped last; QRS-1418; QRS-1410 superseded
B6Audit 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 reversethe 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 writerincrement 1, then every action after it

7.3 Important ​

#FindingWhat it changes
I1Password policy. An EF-side rule is bypassable: a signed-in client can call updateUser({ password }) directlyenforce 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
I2Separation of dutiesnobody 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
I3Where the permission check livesevery 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
I4PIIUsers 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
I5Directory scalea 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
I6No scheduler (QRS-885)time-bound assignments are evaluated at check time (starts_at <= now() < ends_at). "Auto-expiry" is derived, never a job
I7Concurrencyoptimistic 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
I8ADR-0032auto-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)
I9Observability@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
I10Testsan 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
I11DoD 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
I12Auth configuration is a production change classthe 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_log growth: 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) ​

#DecisionAmended by
Q1Staff 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 pathits "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)
Q2RBAC 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 reservedseeds generated from one registry (B4); expiry evaluated at check time (I6)
Q3User actions: suspend and reactivate, block and unblock, sign out all devices, view as user. The rest are reservedholds 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)
Q4Scope: platform staff only. Organization and workspace scopes and External Partner go to the enterprise track
Scope addition: Leads & CRM is in the MVP
Q5Sign-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 priorityG5 is superseded by Q7
Q6CRM 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
Q7Staff 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)
Q8Leads store: a dedicated platform table. It never shares rows with merchant leads, and it links to the user or workspace it produced when Wonthe 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 shares packages/*, never imports apps/web, and a lint boundary enforces that. ADR-0034 decides whether apps/web/src/ui becomes 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 beside devv and uatt, set in ADR-0034). Custom domains bound by hand in the Cloudflare dashboard, as apps/web does, because @cloudflare/vite-plugin drops the env.* 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 inherits deploy-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', with connect-src limited to Supabase and observability; frame-ancestors 'none'; noindex; no-store on 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.com is 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 stays operator_can inside every RPC and EF (I3).
  • 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.

EnvironmentExisting (apps/web)New (apps/admin: the panel and /handbook/)
Devqrsetu-web-dev → devv.qrsetu.comqrsetu-admin-dev → admin-devv.qrsetu.com (the name is set in ADR-0034)
UATqrsetu-web-uat → uatt.qrsetu.comqrsetu-admin-uat → admin-uatt.qrsetu.com
Prodqrsetu-web-prod → qrsetu.comqrsetu-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, not admin.devv), so Universal SSL's *.qrsetu.com covers 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 --name per Worker; custom domains bound in the dashboard (a runbook step); the deploy job added beside deploy-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.com is therefore required. One policy gates the panel and the handbook, allowing @qrsetu.com identities; 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/theme is extracted into one package (for example tooling/vitepress-theme) used by the dev portal and the handbook, so there is one brand and no copy-paste drift. The Mermaid font rule (the config.mjs header) moves with it.
  • Not documentation/portal/, the engineering source of truth: a different audience, with architecture, security and credentials content. Not a new documentation/operations/: everything under documentation/ except portal/ 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 a lastVerified commit. A change to a feature's route or EF without a handbook edit fails it, unless the commit carries a Handbook-Impact: trailer (the model of check:docs-impact). It prints a summary line on every run.
  • The handbook builds in the admin build. Broken links fail the build: ignoreDeadLinks is 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 letterADRSubject
AADR-0034the admin origin, its session and the edge gate (Cloudflare Access); the web-primitives question; the dev and UAT hostnames
BADR-0035operator identity, the permission registry and the admin data plane, amending the operator proposal and ADR-0006
CADR-0036account holds and their enforcement
DADR-0038 (reserved, not yet written)the read-only support view inside admin, which replaces impersonation after spike (d) (§8.6; QRS-1418)
EADR-0037the 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.

  1. 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.js ACTIONS, 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.
  2. 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.

SpikeQuestionWhat it must showResult, 2026-09-28UnblocksRow
(a) ZeptoMail inviteDoes 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 passPending the owner's test inboxincrement 1's invite and reset; closing QRS-285QRS-285
(b) Revocation primitiveCan one user be signed out everywhere, now?a ban plus session and refresh-token invalidation, after which an existing refresh token failsMeasured. 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 callholds, sign out all devices, staff offboarding, the manage-account fixQRS-1413, QRS-1415, QRS-1433
(c) Staff principal creationCan 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 grantsthe first operator migrationQRS-1414
(d) JWT signing mode and impersonationCan 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-0038QRS-1418
(e) Directory at 100kDoes 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 usersincrement 2QRS-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 industries keys (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.com Worker, 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.toml entry. Invite uses auth.admin.inviteUserByEmail with redirectTo pointing at the admin origin's set-password route; reset uses the recovery link (generateLink of type recovery, or resetPasswordForEmail) 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 wrote operator_can(module, action) beside is_platform_operator()), all definer, revoked and commented, seeded from the registry; the ops and operator slugs reserved (QRS-902; admin is already reserved, 20260808110000_v2_reserved_slugs.sql:263); the guard in provision_merchant_workspace() (C1, QRS-900). requireAdmin() is retired, and requireOperator(module, action) in _shared/auth.ts runs 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/admin dev 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.html on :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() (does auth.sessions still hold the token's session_id), at about 30 µs a call, then rolled it back.
  • Sign out all devices, on the primitive spike (b) measured (a service-role definer that deletes auth.sessions), and the fix to manage-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_at stays 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 from anon and authenticated and read through definer RPCs:
    • tables: platform_leads (a stage key, owner_user_id pointing at an operator, an industry_key FK, source, priority, next_action_at, stage_since, the lost reason, fields jsonb with a GIN index, converted_user_id and converted_workspace_id); append-only platform_lead_stage_events and platform_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 the own scope.
  • No consent, no sending and no contact table (Q6). A refused lost 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 merchant leads (Q8). They are the superseded task list's proposal, and ADR-0037 (Proposed) adopts them, naming the field registry platform_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 impersonation claim, an impersonation_sessions table, requireAuth refusing 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).

DeskShips in the MVPReserved: store named, visible, never fakedAmended by
ShellStaff 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 linkthe reminders and notifications popovers (no store); unbuilt desks' nav rows say "not ready yet" (R-05 availability)B1, §8.4
Access controlSystem 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
UsersThe 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, resetB2, B5, I4, I5
Leads & CRMA 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
OverviewThe 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 statesevery 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 idWhat it wasNow
P0.1 to P0.4the owner's answers; the proposal amendment; the ADR-0011 and ADR-0028 amendment; the doc corrections and tracker rowsStage 0 (§9.2)
P1the design correctionsStage 1 (§9.4)
T-B0confirming ZeptoMail deliveryspike (a)
T-B1operator tables, operator_can, the seeds, the slugs, the provisioning guardincrement 1, after spike (c); the seeds generated from the registry (B4)
T-B2record_audit_event() and the audit read RPCincrement 1, atomic (B6)
T-B3requireOperator(module, action)increment 1
T-B4the staff and assignments EF, including "expire"increment 1; expiry is evaluated at check time, never a job (I6)
T-B5the user actions, by widening users.statusincrement 2, as account holds with enforcement (B2)
T-B6the read RPCs, as a definer join over auth.usersincrement 2, as a read model (I5); the Overview snapshot in increment 4
T-B7the pgTAP and Deno testsevery increment, plus the generated authorization matrix (I10)
T-B8view as userreplaced: increment 5 is the read-only support view (§8.6); no impersonated session is built
T-B9the Leads & CRM backendincrement 3
T-W1the @supabase/ssr cookie session scoped to /adminretracted (B1)
T-W2 to T-W6the route family, the primitives, the shell, observability and i18n, the admin seamincrement 1, in apps/admin
S1 to S5the screens: sign-in, Access control, Users, Leads, Overview, view as userS1 and S2 in increment 1, S3 in 2, S3b in 3, S4 in 4; S5 became the support view in increment 5 (§8.6)
P5validationeach 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/*.js model, 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

  1. Stop that flow only. Independent parts of the same screen continue. Record the gap: a tracker row (max+1); the parity-contract row marked blocked with the reason "awaiting design round N"; never left blank.
  2. Batch by screen. All the gaps found on one screen go into one round rather than a stream of small prompts.
  3. 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; names prototype/admin-panel/ and the platform/*.js modules 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 in config.mjs and in the mandate's round table, and it must pass npm run check:design-prompt.
  4. The owner passes the prompt to Claude Design. Claude never sends it and never edits the design project.
  5. When the owner says it was passed, fetch fresh: list_files, then get_file for 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.
  6. The owner approves the returned design. Then the parity-contract rows move from blocked to 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-check and npm test, reading the counts; npm run test:db (pgTAP) and npm run test:ef;
  • check:sql, check:naming, check:readmes, check:i18n-keys, check:docs-impact, check:arch-proposal, check:design-parity, check:claims and check: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, and no-store on 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=pass and 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.js and contacts.js. Claims resting on them are the plan's enumeration, cross-checked against the SCREENS.md prose 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 ​

  1. 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.
  2. 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 from pg_proc on Dev, not from any scan of the repo; with the support view (§8.6) there is no impersonated session left to guard.
  3. admin is already a reserved slug (20260808110000_v2_reserved_slugs.sql:263). QRS-902 is about ops and operator; the old task list reserved admin too.
  4. 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-password and the old verification's "/admin unauthenticated" predate B1. ADR-0034 D7 now sets the paths inside the admin origin (/ for the panel, /sign-in, /set-password as the single invite and reset target, /handbook/; desk paths with the first desk) and withdraws the old two. The same section covers qrsetu.com/admin: routes.ts declares no admin route, and the :slug loader 301s any undeclared root to /<slug>/setu-card with no reserved-slug check (apps/web/src/app/routes/setu-card-redirect.tsx:21-26), the mechanism routes.ts:79-91 records for /marketplace and /merchant. So today qrsetu.com/admin would 301 to /admin/setu-card (repo-read, not requested live). D7 requires /admin to be declared, recommending a single 301 to https://admin.qrsetu.com/, with a plain 404 as the alternative, in increment 1.
  5. 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.
  6. 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.
  7. 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.
  8. Custom domains by hand. Resolved in ADR-0034 D2, to be measured in increment 1. apps/web binds its domains in the dashboard because @cloudflare/vite-plugin drops the env.* routes (apps/web/wrangler.jsonc:11-35). ADR-0034 D2 now says that reason belongs to the SSR build: apps/admin may 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.
  9. 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 copy Users.dc.html:823 (820). The branded templates are under supabase/email-templates/rendered/. check:naming N2 bans five names in five trees, not three under apps/. This page uses the remeasured values. (requireAdmin() is at _shared/auth.ts:85-99 today, as the plan said.)
  10. 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 ​

RowSubjectWhere on this page
QRS-1404The programme: Admin Panel MVP§1, §9
QRS-1405Staff sign-in design (G1 to G4 and A2; G5 is superseded by Q7)§3.2, §4
QRS-1406The staff account lifecycle (D1)§4
QRS-1407One role model (D3, D4, D13, D14)§4, B4
QRS-1408Access control is platform staff only (D5)§4, Q4
QRS-1409The availability ("not ready yet") state (D6, D7, D16, D18)§4, §9.6
QRS-1410Impersonation, the user-facing design (D10); superseded by the support view§4, B5, §8.6
QRS-1411The Leads import and the Won link (D15, D17)§4, I8
QRS-1412Copy and controls (D8, D9, D11, D12)§4
QRS-1413Global sign-out by user id has no working primitive (measured by spike (b))§6.3, B2, §9.3
QRS-1414Staff principals collide with the tenant plane§3.2, B3
QRS-1415Account holds and their enforcementB2, increment 2
QRS-1416The admin origin decisionB1, §8.2
QRS-1417The password policy, project-wideI1, §8.2
QRS-1418The impersonation spike (d): measured, a user token cannot be mintedB5, §9.3, §8.6
QRS-1419The Operations Handbook§8.4
QRS-1420Admin screens sit outside every ledger§5.3
QRS-1421@qrsetu/observability is not a dependency of apps/web§5.1, I9
QRS-1422The documentation corrections batch§6.7
QRS-1423rate_limits cites a _shared/rateLimit.ts that does not exist§6.5
QRS-1424A false claim on the landing pagenot discussed here; the row holds the evidence
QRS-1425WhatsApp template status events are recorded and never acted on§6.4
QRS-1426The report tables carry no resolution§6.4
QRS-1427The platform leads store decision§6.5, Q8
QRS-1428qrsetu.com/admin falls into the :slug catch-all§12.2 item 4 (ADR-0034 D7)
QRS-1431citext under search_path = publicthe spike results page
QRS-1432A session-less token; the legacy HS256 secretthe spike results page
QRS-1433An unban revives sessions§9.3 (b), §8.6, increment 2
QRS-1434Redirect, link and invite behavioursthe spike results page
QRS-1435Local-stack auth gapsthe 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 ​

Pages ​