Appearance
Subscriptions (admin panel)
File: prototype/admin-panel/Subscriptions.dc.html (127 KB) · Status: 🔴 Findings logged · 🟢 Rounds 2, 3 and 4 applied and SIGNED OFF on the model, 2026-08-23. Visual review outstanding · Last reviewed: 2026-08-23 (full file fetched and grepped directly from the Claude Designs project)
Eight tabs: Plans, Add-ons, Promotions, Subscribers, Custom deals, Insights, Approvals, Audit. Token discipline is genuinely strong here (764 var(--token) references counted, only 2 hardcoded hex values found and both are var(--danger-soft,#fee2e2)-style fallbacks, not raw usage; 0 native <select> elements).
Finding 1 — No accessibility support in a keyboard-dense operator screen
Severity: blocker for an internal operator tool at this density
Full-file check: 1 aria-* attribute, 0 role= attributes, 0 keyboard handlers (onKeyDown/onKeyPress/tabIndex) across the entire 127 KB file. This screen has 8 tabs, multiple modals, a custom dropdown (dsSelect), tables, and bulk-action bars — none of it is confirmed keyboard-operable.
Design-fixable — copy-paste prompt for Claude Designs
markdown
In prototype/admin-panel/Subscriptions.dc.html, add real keyboard and screen-reader support before this goes
further:
1. Every tab in the tab bar needs role="tab" / role="tablist" / role="tabpanel" and arrow-key navigation
between tabs, not just click handlers.
2. The dsSelect dropdown (used throughout this screen) needs a keyboard path: Enter/Space to open, Arrow
Up/Down to move selection, Enter to confirm, Escape to close, and it needs to be focusable (tabIndex) with
a visible focus ring using the --focus-ring token.
3. Every icon-only button (e.g. bulk action icons, row action icons) needs an aria-label describing what it
does, not just a tooltip.
4. Modals need role="dialog", aria-modal="true", and focus should move into the modal on open and return to
the trigger element on close.
5. Data tables need proper semantic markup (or explicit role="table"/"row"/"cell" if not using native table
elements) so row content is announced correctly.
Do not change any visual styling, spacing, or tokens. This is purely about adding the missing interaction and
semantic layer on top of the existing visual design.Finding 2 — Organization/franchise/seat-based billing is not modeled
Severity: major (real gap, but a data-model question — see architecture note below)
Confirmed: PLANRANK={free:0,pro:1,business:2} covers individual/business plans. Custom deals exist with DSCOPE={org:['Organization',...], workspace:[...]} and explicit copy: "Enterprise is quote-based. Pricing is set per deal in Custom deals; the entitlements here are the default template." Per-seat pricing exists only as one add-on's pricing unit (unit:'/ seat / mo'), not a licensing model. A full-file search for "franchise" and "family plan" returned zero occurrences of either.
So today: "Enterprise" means "go negotiate a manual custom deal." There is no self-serve seat management, department-level sub-billing, or multi-location franchise rollup modeled anywhere.
Architecture-gated — not a Claude Designs prompt yet (tracked as QRS-043)
This needs a new Organization entity above the existing Workspace (see CONTRACTS.md §2's Account → Workspace(tenant) → Member(role) model) with a type (enterprise / franchise / community / NGO / family) and seat/member management, decided during core architecture planning. Once that data model is decided, a design-fixable prompt can ask Claude Designs to design the actual seat-management and org-admin UI against it. Sending "please design organization billing" to Claude Designs today would just produce more UI without a real entity behind it, which is how the current Custom Deals tab ended up being a manual-only escape hatch instead of a real feature.
Finding 3 — In-house billing engine modeled instead of a payment-provider integration
Severity: major (architecture decision, not a screen defect)
The screen models proration, dunning states (past_due, grace), refunds, and custom pricing as if this will all be custom Postgres + Edge Function logic. Building this in-house (rather than on a PSP like Razorpay or Stripe Billing as the system-of-record for money) is a significant reliability and compliance risk for a platform of this scale.
Architecture-gated — not a Claude Designs prompt (tracked as QRS-044)
This is a backend/vendor decision to make during core architecture planning, not something to send back to Claude Designs. Once decided, the entitlement-state UI (Plans, Subscribers tabs) mostly stays as designed since entitlement state belongs in Postgres regardless of which PSP handles the money; only the money-handling surfaces (refund flows, proration display, dunning detail) would need a follow-up design pass to match the chosen provider's actual capabilities.
Round 2 review — 2026-08-23
📘 Triggered by the owner while validating the admin panel against the car dealership model, which is the platform's first serious test of custom per-customer subscriptions. Full architecture validation: verticals/car_sales/admin-control-plane. 🧮 The schema was read, not assumed — feature_grants already carries eight scopes with precedence held as data, three axes, limit_value, limit_period, on_exceed and a set_by chain, and its own table comment says the precedence is data "because it is READABLE BY THE ADMIN PANEL." So these are presentation findings against a capability the database already has.
Finding 4 — Customer type is invisible, and the owner's proposed fix would make navigation unstable
📘 Reported: "I don't see a clear distinction between Enterprise clients and Solo/SMB merchants." Correct. ⚠ But the proposed remedy — separate Solo/SMB and Enterprise tabs — is the wrong instrument, argued in control plane §10: enterprise is a shape of deal, not a type of customer; a tab forces a binary whose re-litigation moves records between tabs; two tabs are two implementations of one list; and tabs do not solve scale, they hide it in one tab.
Design-fixable. One segmented list with saved views, plus a segment-aware detail view.
Finding 5 — No client-level drill-down, so the panel cannot answer its own core question
An operator cannot answer "what exactly did we sell this client, what are they entitled to, what are they using, what add-ons are active." Design-fixable, and two additions make it answer properly: provenance on every entitlement row (which scope won, and what it overrode) and a diff against the base template as the primary view.
Finding 6 — New Deal and New Add-on read as placeholders
Design-fixable. Both need a defined workflow. 🔎 And New Add-on is in the wrong place: an add-on is a reusable grant bundle plus a price plus an eligibility rule, which makes it the same kind of object as a plan. A creation button inside a list of instances has nothing coherent to create.
Finding 7 — Disabled features are not split by REASON
📘 ADR-0021: effective = applicability AND entitlement AND availability. A single disabled list would make the panel lie to its operator: an applicability row is not an upsell, an availability row is not sellable at any price, and only entitlement is a commercial conversation. Design-fixable.
Finding 8 — The resolver must return which scope won
Architecture-gated — not a Claude Designs prompt (QRS-864)
⚠ The panel cannot render provenance unless the resolver returns the winning scope alongside the value. Without it the client re-derives precedence in JavaScript — a second implementation of the precedence rule, guaranteed to disagree with the database eventually. Resolve before Finding 5's prompt can be implemented, though the design can proceed.
⚠ Also architecture-gated, and already tracked — do not send these to Claude Designs
| No subscription record (workspace → plan, status, renewal, term, price) | QRS-863 · relates to Finding 2 / QRS-043 |
No usage ledger, so consumption against limit_value cannot be shown | QRS-863 |
| No role templates, so the wizard's persona step has nothing to instantiate | QRS-863 |
RBAC 0% built, role scope missing from schema and from RBAC.dc.html | QRS-863 |
Design-fixable — copy-paste prompt for Claude Designs
⚠ Covers Findings 4, 5, 6 and 7 in one pass, because they are one screen and splitting them would produce four partial redesigns of the same nav.
Click to expand the full prompt
text
Revise prototype/admin-panel/Subscriptions.dc.html.
CONTEXT YOU NEED, AND IT IS ALREADY REAL IN THE DATABASE
This screen drives a live entitlement model. Do not invent a different one.
Entitlements resolve through EIGHT SCOPES, most specific wins, precedence held as data:
platform 10 · archetype 20 · industry 30 · plan 40 · workspace_group 50 ·
workspace 60 · workspace_member 70 · user 80
Three independent axes, and they are NOT interchangeable:
applicability does this workflow apply to this business at all
entitlement is it unlocked on this plan, and how much
availability is it shipped yet
A grant carries: effect (grant or deny), limit_value, limit_period,
on_exceed (block_new | read_only | grace_period), and set_by with the
precedence platform_admin > org_admin > vendor > derived-default.
So "Dealer A: 10 Setu Cards, AI disabled" is two rows at workspace scope
overriding the plan scope. Custom plans are DATA, not a separate product.
CHANGE 1: INFORMATION ARCHITECTURE. Do NOT add Solo/SMB and Enterprise tabs.
Reasons, so the decision is not reversed later: "enterprise" is a shape of deal
rather than a type of customer; a tab forces a binary whose re-litigation moves
records between tabs, which breaks an operator's muscle memory and makes "where
did that account go" a support question about our own tool; two tabs become two
drifting implementations of one list; and tabs do not solve scale, they hide it
in one tab, because 5,000 SMBs in an SMB tab is still an unusable list.
Instead, restructure the tabs by OBJECT TYPE and segment WITHIN the list:
Overview an operational queue, not vanity metrics: expiring in 30 days,
past-due, approvals pending, over limit, deals awaiting activation
Subscribers ONE list. Every row carries a SEGMENT CHIP plus outlets, seats,
deal type and ACV. Saved views are the primary navigation and are
user-extensible: "Enterprise / active", "SMB / expiring 30d",
"Custom deals / pending", "Over limit", "Trials ending".
Custom deals is a FILTER here (deal_type = custom), not a
separate list, because a custom deal IS a subscription.
Plans productized. Versioned, published, inherited.
Add-ons productized, and MOVE the New Add-on action here from Custom deals.
Promotions · Insights · Approvals · Audit unchanged.
The segment chip is DERIVED and must never be a hand-set field, because a
hand-set customer type goes stale in the first week and then quietly lies.
Derive it from workspace count, seats in use, ownership model, whether any
workspace or workspace_group scope override exists, contract presence and ACV
band. Show the derivation on hover, for example "Enterprise: 6 outlets, 41
seats, negotiated price", because a classification an operator cannot
interrogate is one they stop trusting.
CHANGE 2: CLIENT DRILL-DOWN. Opening a subscriber opens one record with these
sections, in this order:
AT A GLANCE ACV, contract term, renewal, owner, payment status, and the
count of overrides against the base template
WHAT DIFFERS the PRIMARY view. Only the rows that differ from the base
plan, each with PROVENANCE: which scope set it, by whom, when,
and what it overrode. For example:
AI actions DISABLED set at workspace scope by platform
admin, 12 Aug, overriding plan Growth which
grants 2,000 per month
The full resolved table is one click away, never the default,
because an operator wants the 6 overrides and not the 80
inherited rows.
ENTITLEMENTS x USAGE every limit as "used of entitled", with the on_exceed
policy named, and an over-limit row visibly over
DISABLED, AND WHY THREE separate groups, never merged into one list:
applicability (does not apply to this business, no upsell
exists), entitlement (not on this plan, this is the upsell),
availability (not shipped, not sellable at any price)
ROLES PROVISIONED which templates were instantiated, who holds them,
and the subtree each is scoped to
ADD-ONS ACTIVE each with its own term and meter
OUTLET BREAKDOWN per outlet: plan, cards, seats, usage, own overrides.
Render this section ONLY when the account has more than one
outlet. This is where the detail genuinely diverges by
segment, and it is why the lists do not need to.
COMMERCIAL negotiated price, cycle, invoices, GST shown as added at 18
percent, approval trail
HISTORY every change: who, when, at which scope, what it overrode
An account that grows from one outlet to six must GAIN the outlet section, never
move to a different list.
CHANGE 3: NEW CUSTOMER DEAL becomes a real wizard:
1 select customer 2 confirm the DERIVED segment, shown not chosen
3 base plan or template 4 customise features 5 limits and on_exceed
6 roles and personas 7 integrations 8 paid add-ons
9 pricing, contract term, renewal
10 REVIEW, which shows the DIFF against the base template and flags what
needs approval. A review step that restates what was typed is theatre.
11 activate, writing an audit row and entering the approval queue if pricing
changed
Steps 6 and 9 depend on stores that do not exist yet. Design them, and mark them
in the design as depending on a store that is not built, using the same
convention platform/capabilities.js already sets for absent stores.
CHANGE 4: NEW ADD-ON becomes a real wizard, in the Add-ons tab:
name, category, description · the GRANT BUNDLE it applies and on which axis ·
pricing model flat, per-seat, per-unit or metered · usage limits and on_exceed ·
eligibility by INDUSTRY and ARCHETYPE, not by an invented segment taxonomy ·
activation rules immediate, next cycle or needs approval ·
visibility public, private or hidden · publish with a version and an audit row
CARRY FORWARD, STILL OPEN FROM ROUND 1
Keyboard and screen-reader support in a keyboard-dense operator screen: focus
order, focus-visible rings on every control, aria-labels on icon-only buttons,
Escape closing every modal and returning focus, and arrow-key navigation in the
tables. This screen is used all day by internal staff.
RULES THAT APPLY TO EVERYTHING YOU ADD
Tokens only. No hardcoded colour, radius, spacing or font size. No native
select elements. Light and dark must both work.
A capability with no store behind it renders as ABSENT, never as a disabled
control and never as "coming soon", which is the platform rule.
DO NOT use em dashes or en dashes anywhere in any copy, label or example. Use a
comma, a colon, parentheses or two short sentences.
Do not volunteer a privacy reassurance next to a control. State what the product
does, never what it refrains from doing.
Do not add a screen-level permission matrix. Screen visibility is DERIVED as
module entitled AND role has read, and a third independent matrix would create
two places to say one thing.
Do not invent revenue, churn or MRR figures beyond what the existing Insights
tab already shows.Round 3 revalidation — 2026-08-23, after the Round 2 prompt was applied
🧮 Measured, not eyeballed: the file was fetched (220.6 KB, up from 127 KB) and audited by script, and the new shared module prototype/platform/entitlements.js was read in full. ⚠ My first audit reported SMB, Solo and Dealership as MISSING and that was a FALSE NEGATIVE — they live in the shared module, not in the screen's own markup, which is the correct place for them. Recording the false negative because a grep over one file is not a measurement of a two-file design.
✅ The verdict: the architecture question is now ANSWERED, and better than asked for
📘 QRS-864 said the one thing to fix before any screen was drawn was that the resolver must return which scope won, not just the value. entitlements.js resolve() now returns scope, scopeRef, set_by, by, at, note, overrode (the full list of rows it beat), base (the plan row) and differs. Gap 15 is closed at the model level.
🔎 And three properties nobody asked for, each of which prevents a real defect:
- AVAILABILITY OUTRANKS EVERY SCOPE.
resolve()checks the axis before reading a grant row, so "no amount of platform-admin authority can turn on a store that does not exist." Fixturegr7proves it: SSO is signed for and not shipped, and the grant resolves to nothing. unlocksOn()returns null when no plan grants a workflow, so a screen "never invents an upgrade path that does not exist."history()is derived from the grant rows, so "the record and its history cannot drift apart."
What the owner asked, answered
| Question | Verdict |
|---|---|
| Custom plans per segment, client level, no code change | ✅ Yes. GRANTS is a flat row list at any of eight scopes. The module states it outright: "a custom deal IS a subscription with rows at a higher scope, and the resolver cannot tell the difference" — hence no custom-plan type and no custom-deal list |
| Can Admin see what a client bought, enabled, limited, roled, added, priced | ✅ Yes. differences() for the diff, usage() for used-of-entitled with the on_exceed policy named, disabled() returning three lists, addonsFor() with a per-add-on term and meter, history() for who changed what at which scope |
| Add-ons: create, configure, assign, manage; tied to segments and plans; scalable | ⚠ Partly. The wizard, the per-account attachment, the term and the meter are all real. The category taxonomy is not — see Finding 9 |
| Segments clearly differentiated, and is this the best UX | ✅ Yes, and the IA recommendation was implemented with its reasoning intact. The file carries the comment "TABS ARE OBJECT TYPES, never customer types: an account that grows from one outlet to six gains detail inside its record and never moves list." ⚠ But two "segment" vocabularies now coexist — see Finding 10 |
Finding 9 — Add-on cards carry a PRICING label, not a DOMAIN category
🧮 ADDONS carries type: with values Per-seat · Feature pack · Feature · Service · Usage, and category: appears zero times in the file.
⚠ So the cards do have a label, which is why this can look done. It is on the wrong axis: type is a pricing and delivery shape, and the owner asked for a domain label so cards can be scanned and filtered by what they DO. Both are useful and they are orthogonal — an operator filters by domain (show me the messaging add-ons) and sorts by pricing model (which of these are metered).
Design-fixable. Add category alongside type, derived from the real catalogue: Presence · Operations · Engagement · Team already exist as WORKFLOW_GROUPS in entitlements.js, so the add-on taxonomy should extend that vocabulary rather than invent a parallel one: AI · Messaging · Marketing · Analytics · Integration · Team · Managed service · Usage.
Finding 10 — ⚠⚠ TWO different "segment" vocabularies now exist in one screen
This is the duplicate-source-of-truth defect, live, and one of the two lists mixes two axes inside itself
🧮 Measured:
| Where | Values | What it actually means |
|---|---|---|
E.SEGMENTS (shared module) | enterprise · multi · smb · solo | A derived size and deal shape. Correct, and it is what the row chip and the segment filter read |
this.SEGMENTS (local array in Subscriptions.dc.html) | Food & Beverage · Retail · Services · Healthcare · Freelancers · NGO / Nonprofit · Enterprise | A hand-set industry tag, used for plan targeting |
Two problems, and the second is worse than the duplication:
- One word, two meanings, in one screen. An operator reading "segment" cannot know which is meant.
- ⚠ The second list mixes an industry taxonomy with a deal shape. Enterprise sits beside Retail, so a plan cannot be targeted at "retail businesses" and "enterprise accounts" — the field holds one value and the two are not alternatives. A 24-outlet boutique chain is both.
📘 This is QRS-863 gap 11 instantiated. Fix: plan targeting keys on INDUSTRY x ARCHETYPE, which entitlements.js already models (WORKFLOWS.industries, WORKFLOWS.archetypes, INDUSTRY_GRANTS, ARCHETYPE_GRANTS), and the word segment is reserved for the derived size class.
Finding 11 — No dealership fixture, and the dealership workflows do not exist
🧮 The 13 account fixtures cover restaurant, salon, boutique, tutors, yoga, clinic, plumber and property. There is no car dealership, and WORKFLOWS has no dealership entry — no test drives, no visitor register, no enquiry inbox.
🔎 The mechanism handles it, because an industry is a data row. But the screen has never been exercised against the shape we are about to design for: a 6-outlet dealer group with 40+ cards, per-outlet card caps and AI denied at one outlet. Design-fixable and cheap — one fixture, and it is the highest-value one to add because it is the next vertical.
Finding 12 — acv() has a special case that a segment threshold depends on
🧮 acv() contains:
js
const outlets = acc.dealType === 'custom' && acc.cycle === 'quarterly' ? 1 : acc.outlets;⚠ So annual contract value is computed differently for custom quarterly deals than for everything else. And segment() classifies enterprise partly on acc.contract && value >= 300000, so a data-shaping special case is load-bearing on a classification the whole list is sorted and filtered by.
Design-fixable. Either the fixture's price is per-outlet and the formula should not multiply, or it is total and the formula should never multiply. Pick one and remove the branch — a conditional inside a money formula is where a wrong number hides.
Finding 13 — Resolution is O(accounts x workflows x grants) with no memoisation
Architecture-gated — not a Claude Designs prompt (QRS-866)
🧮 rows() calls segment() per account; segment() calls differences() → resolveAll() → 18 resolve() calls, each sorting its candidate rows; and overLimit() calls usage() → resolveAll() again. 13 accounts is fine. The owner's stated concern is 5,000.
🔎 Not a prototype defect — it is the right shape for a prototype. ⚠ But the module's own lift note says the resolution becomes "a Postgres function so the API and the UI can never disagree", and a per-row function is exactly what must not be built: the list view needs a set-returning resolver that answers for many accounts in one query, or the Subscribers list becomes the slowest screen in the product.
Design-fixable — copy-paste prompt for Claude Designs, round 3
Click to expand
text
Revise prototype/admin-panel/Subscriptions.dc.html and prototype/platform/entitlements.js.
Round 2 landed well. Four corrections, all small, and two of them are one-line
data changes.
FIX 1: ADD-ON CARDS NEED A DOMAIN CATEGORY, NOT ONLY A PRICING SHAPE.
ADDONS currently carries type: Per-seat, Feature pack, Feature, Service, Usage.
That is a pricing and delivery shape. Add a SECOND field, category, for what the
add-on DOES, and show it on the card as a visible chip that the Add-ons tab can
filter by. Keep type as well: an operator filters by domain and sorts by pricing
model, so both are useful and they are orthogonal.
Use this taxonomy, which extends WORKFLOW_GROUPS rather than inventing a
parallel one:
AI · Messaging · Marketing · Analytics · Integration · Team · Managed service ·
Usage
Map the six existing add-ons: seats to Team, analytics to Analytics, domain to
Integration, whatsapp to Messaging, support to Managed service, cards to Usage.
Add a category filter to the Add-ons tab header.
FIX 2: THERE ARE TWO DIFFERENT THINGS CALLED "SEGMENT" AND ONE OF THEM MIXES TWO
AXES. E.SEGMENTS in entitlements.js is the derived size class (enterprise, multi,
smb, solo) and it is correct. The local SEGMENTS array in Subscriptions.dc.html
is ['Food & Beverage','Retail','Services','Healthcare','Freelancers','NGO /
Nonprofit','Enterprise'], used for plan targeting, and it has two problems: it
reuses the word segment for a different concept, and it puts "Enterprise", which
is a deal shape, inside a list of industries. A 24-outlet boutique chain is both
retail AND enterprise, and the field can only hold one.
Rename that control to PLAN TARGETING and key it on the two axes the platform
already models: INDUSTRY (restaurant, salon, boutique, clinic, tutors, yoga,
plumber, property, dealership) and ARCHETYPE (goods, time, expertise). Remove
"Enterprise" from it: an enterprise account is targeted by the derived segment
filter, not by a hand-set industry tag. Reserve the word "segment" for the
derived size class everywhere in the screen.
FIX 3: ADD ONE CAR DEALERSHIP FIXTURE, because it is the next vertical and the
screen has never been exercised against its shape. Add to ACCOUNTS:
a dealer group, industry 'dealership', archetype 'goods', ownership 'org',
6 outlets, 41 seats, plan 'enterprise', dealType 'custom', a 36 month
contract, and outletRows showing per-outlet overrides
Add to WORKFLOWS, so the dealership has something to be entitled to:
visitors 'Visitor register' group Operations limit, unit visits, month
testdrive 'Test drive scheduling' group Operations flag, industries ['dealership']
enquiries 'Enquiry inbox' group Operations limit, unit enquiries, month
Give the dealer group two GRANTS rows so the diff view has something real to
show: cards capped at 24 at workspace scope for one outlet, and ai_actions
denied at another.
FIX 4: REMOVE THE SPECIAL CASE IN acv(). It currently reads
const outlets = acc.dealType === 'custom' && acc.cycle === 'quarterly' ? 1 : acc.outlets;
A conditional inside a money formula is where a wrong number hides, and segment()
classifies enterprise partly on this value, so the branch is load-bearing on a
classification the whole list is sorted by. Decide whether price is per outlet or
total, make every fixture consistent with that decision, and delete the branch.
RULES, unchanged from round 2
Tokens only, no hardcoded colour or radius, no native select elements, light and
dark both work. A capability with no store behind it renders as ABSENT, never
disabled and never "coming soon". DO NOT use em dashes or en dashes in any copy,
label or example. Do not add a screen-level permission matrix.Round 4 revalidation — 2026-08-23, after the Round 3 prompt was applied
🧮 Both files fetched and scripted: the screen (226.4 KB, 1,901 lines) and the shared module. ⚠ Three of my own audit greps were FALSE NEGATIVES again — type survives as a quoted key 'type':, the wizard's persona step exists in a step array rather than as literal markup, and account prices live in the module not the screen. A regex that assumes a syntax is not a measurement. All three corrected before reporting.
✅ All four Round-3 fixes landed, and two exceeded the ask
| Fix | State |
|---|---|
| 1 · Add-on domain category | ✅ Full. category on all six add-ons (Team, Analytics, Integration, Messaging, Managed service, Usage), a category filter control, and type retained so the pricing-shape axis survives alongside the domain axis, exactly as asked. 🔎 AI and Marketing are absent because no add-on occupies them yet — correct behaviour, not a gap: inventing a category with no member is the thing capabilities.js exists to prevent |
| 2 · Plan targeting vs segment | ✅ Full. The local SEGMENTS array is gone. TARGET_INDUSTRIES (9, including dealership → "Car dealerships") and TARGET_ARCHETYPES (goods/time/expertise) replace it, Enterprise is removed from the industry list, and add-on eligibility keys on both axes. The reasoning is preserved in the code: "a chain is retail AND enterprise, so a single hand-set tag could only ever hold half the truth" |
| 3 · Dealership fixture and workflows | ✅ Full. visitors, testdrive (gated industries:['dealership']) and enquiries added to WORKFLOWS; INDUSTRY_GRANTS.dealership; Sahyadri Motors Group — 6 outlets, 41 seats, org owned, 36-month contract, per-outlet overrides; two grant rows exercising workspace scope and an org_admin set_by; three add-ons with their own terms |
4 · The acv() special case | ✅ Full. The branch is gone and the rule is stated: "PRICE IS PER OUTLET. One rule, no branch: a conditional inside a money formula is where a wrong number hides." Fixtures re-based to match |
✅ Discipline holds: 1,283 token references, 0 hardcoded hex, 0 native <select>, 0 em or en dashes in visible copy, 36 aria-label, 29 role=, focus-visible and Escape handling present.
Finding 14 — ⚠⚠ Persona and login control does not exist, and it is NOT a design gap
📘 The owner asked which of three explanations applies. The answer is C, and the design handled C correctly.
| 🧮 Measured | Zero occurrences of Receptionist, Sales Rep, Team Lead, CRM Manager, Sales Manager, GM, Dealer Principal, Service Advisor or Org Admin. Zero persona toggles |
| ✅ But the step EXISTS | The wizard's step array carries ['roles','Roles and personas'], and it renders through capabilities.js: absentTitle: cap.label + ' are not stored yet', absentStore: cap.store, absentReserved: cap.placements. The record view says "No role has been provisioned from here" and names the missing store |
| A — we never asked? | ❌ Partly, and deliberately. The Round-2 prompt said "steps 6 and 9 depend on stores that do not exist yet. Design them, and mark them as depending on a store that is not built" |
| B — Claude Design missed it? | ❌ No. It did the right thing. ⚠ Drawing a persona checklist over a non-existent role model would have LOOKED complete and been undeliverable — which is precisely the failure the owner is trying to avoid |
| C — not defined in our architecture? | ✅ YES, and this is the answer. 🧮 roles, permissions, role_assignments do not exist; is_admin() has zero definitions; role SCOPE is missing from the schema and from RBAC.dc.html; role templates are not a concept anywhere. 📘 QRS-863 gaps 4, 5, 6 and 9 |
The consequence for sequencing, stated plainly
This cannot be fixed by a design prompt. No amount of UI work produces persona control over a role model that does not exist. It is a schema decision, and it is the gate on the dealership MANAGEMENT screens.
🔎 But it does not block everything. The twelve-screen first cut is mostly Core, Wave 1 and single-role — Log a visitor, Today, Visitor queue, Enquiry inbox, My day, My Setu Card, Conversations, Catalogue. Receptionist and Sales Rep screens can proceed now, because their content does not depend on a role matrix. What must NOT be designed yet is Roles & scopes and anything whose visibility is decided by a role — Team Leader, Sales Manager, GM and Dealer Principal views.
Finding 15 — The dealership plan rows do not exist, so the fixture is on a generic plan at an invented price
🧮 The plan catalogue is the generic merchant book: free / pro / business / enterprise / resto / pro-legacy at ₹199-799 per month. That is correct for a chai stall and is not a defect.
⚠ But the dealership fixture was put on the generic enterprise plan at ₹4,200/month. 🧮 At 6 outlets that is ₹3,02,400 a year, or ₹50,400 per outlet per year — against 📘 our own Foundation floor of ₹79,999, which this section withdrew ₹30,000 for being below cost. The screen therefore shows a dealership priced at 63% of our cheapest tier.
The fix is to ADD plan rows, not to change the existing ones
🔎 A ₹299/month chai stall and a ₹1,49,999/year dealer outlet belong in one catalogue with two plan families. resto already proves the pattern exists. Add:
| Plan | Price | Cards | Seats |
|---|---|---|---|
dealer_foundation | ₹79,999 / outlet / year | 10, cap 24 | 5, cap 10 |
dealer_growth | ₹1,49,999 / outlet / year | 30, cap 60 | 12, cap 20 |
dealer_complete | ₹2,99,999 / outlet / year | 75, cap 100 | 25, cap 45 |
plus additional Setu Card ₹4,999/yr, additional management login ₹2,499/yr and QR standee ₹225 as add-ons, and every price shown ex-GST with 18% added on the invoice line.
Finding 16 — ⚠ unlocksOn() assumes ONE global upgrade ladder, and a multi-vertical catalogue has none
This is a correctness bug waiting for the dealership plans to land, and it is cheap to fix now
🧮 unlocksOn() walks a single hardcoded order:
js
const order = ['free', 'pro', 'resto', 'business', 'enterprise'];⚠ It already conflates an industry plan with a tier: resto is a Restaurant Growth plan sitting inside a generic ladder. Today the damage is invisible because no dealership plan exists. The moment Finding 15 lands, the screen will confidently tell a car dealer to "Unlock on Restaurant Growth", which is the single most embarrassing sentence an admin panel can print to its own operator.
Fix: the ladder is per plan FAMILY. unlocksOn(key, family) walks only the plans in the account's own family and returns null outside it — which is the behaviour the function already gets right for "not sold on any plan", extended one dimension.
Finding 17 — No module-group toggle
🧮 21 workflows are individually toggleable; there is no "turn off all of Operations". 🔎 Tolerable at 21 and not at 60, and WORKFLOW_GROUPS already exists as the grouping. Low priority, cheap whenever it is done.
Readiness verdict
✅ The subscription and entitlement plane is ready. The persona plane is not, and they unblock different work
| Question | Answer |
|---|---|
| Can Admin create genuinely custom plans per client, no code change? | ✅ Yes. Grants at eight scopes, precedence as data, provenance in the resolver |
| Can Admin see what a client bought, is entitled to, is using? | ✅ Yes. Diff-first record, used-of-entitled with the policy named, three disabled reasons, per-add-on term and meter, derived history |
| Can add-ons be created, categorised, targeted, scaled? | ✅ Yes, on both the domain and pricing axes, targeted by industry and archetype |
| Are segments clearly differentiated without a mixed list? | ✅ Yes. Derived chip with an explainable why, saved views as navigation, object-type tabs |
| Can Admin control which PERSONAS a client receives? | ❌ No, and not for a design reason. Finding 14 |
| Is the sample data commercially realistic for a dealership? | ❌ No. Finding 15 |
Proceed with: Receptionist and Sales Rep screen design, now. Do not proceed with: Roles & scopes, or any management-persona screen whose visibility is decided by a role, until the RBAC schema decision is taken.
Design-fixable — copy-paste prompt for Claude Designs, round 4
Click to expand
text
Revise prototype/platform/entitlements.js and prototype/admin-panel/Subscriptions.dc.html.
Round 3 landed all four fixes. Two corrections, both in the sample data and the
plan model, so the screen shows a commercially realistic picture.
FIX 1: ADD THE DEALERSHIP PLAN FAMILY. The catalogue is currently the generic
merchant book, free through enterprise at 199 to 799 rupees a month, which is
right for a cafe and wrong for a car dealer. The dealership fixture was put on
the generic enterprise plan at 4,200 a month, which works out at 50,400 per
outlet per year, below the cheapest dealership tier we sell.
Add three plans to PLAN_GRANTS and PLAN_LABELS, priced per outlet per year and
quoted exclusive of GST:
dealer_foundation 79,999 cards 10 cap 24 seats 5 cap 10
dealer_growth 149,999 cards 30 cap 60 seats 12 cap 20
dealer_complete 299,999 cards 75 cap 100 seats 25 cap 45
Give each one the dealership workflows it should carry: visitors, enquiries,
testdrive, leads, wa_marketing, ai_actions. Foundation gets no ai_actions.
Move the Sahyadri Motors Group fixture onto dealer_growth with a per-outlet
price of 149,999 and a yearly cycle, so its ACV reads as 8,99,994 across six
outlets rather than 3,02,400.
Add three add-ons to ADDONS, since these are real lines in our commercial model:
extra Setu Card category Usage 4,999 per card per year
extra management login category Team 2,499 per login per year
physical QR standee category Managed service 225 each, one time
Every price displayed anywhere on this screen is exclusive of GST, and an
invoice line shows 18 percent added. Say "plus 18% GST" next to a price, never
fold it in.
FIX 2: unlocksOn() ASSUMES ONE GLOBAL UPGRADE LADDER AND THERE IS NO LONGER ONE.
It walks const order = ['free','pro','resto','business','enterprise'], which
already puts an industry plan (resto) inside a generic tier list. Once the
dealership plans exist, this function will tell a car dealer to unlock a feature
on Restaurant Growth, which is the most embarrassing sentence an admin panel can
print to its own operator.
Give every plan a family: 'generic', 'restaurant', 'dealership'. Change the
signature to unlocksOn(key, family) and walk only the plans in that family,
returning null outside it, which is the behaviour the function already gets right
for "not sold on any plan". Every call site passes the account's own family.
RULES, unchanged
Tokens only, no hardcoded colour or radius, no native select. A capability with
no store behind it renders as ABSENT, never disabled and never "coming soon".
DO NOT use em dashes or en dashes in any copy, label or example. Do not add a
screen-level permission matrix. Do not draw persona or role toggles: the role
model does not exist yet, and the absent-store treatment you already render for
the Roles and personas step is the correct and honest answer until it does.Round 5 sign-off — 2026-08-23. DELIVERED
🧮 Both files re-fetched and scripted. Every Round-4 item landed, and the module went past the ask twice.
| Asked | Delivered |
|---|---|
| Dealership plan family at our price book | ✅ dealer_foundation / dealer_growth / dealer_complete in PLAN_GRANTS, PLAN_LABELS and on the screen, at ₹79,999 / ₹1,49,999 / ₹2,99,999 per outlet per year. Foundation carries no ai_actions, as specified |
| Dealership fixture re-based | ✅ Sahyadri Motors on dealer_growth, ₹1,49,999 yearly × 6 outlets → ACV ₹8,99,994, and a new workspace_group grant of 72 seats by org_admin: "Twelve logins per showroom, pooled across six" |
| Three commercial add-ons | ✅ extra Setu Card ₹4,999, extra management login ₹2,499, physical standee ₹225 |
| GST shown, never folded in | ✅ Seven places |
unlocksOn(key, family) | ✅ And the call site passes it: unlocksOn(r.key, E.familyOf(a.plan)) |
🔎 Two things it added that were not asked for, and both are right
PLAN_CAPSwith a flag-on-exceed rule. "A CEILING IS A PRODUCT FACT, not a negotiation. The plan grants this much and may be expanded to the cap by add-ons; past the cap the answer is the next tier, so a deal that types a bigger number is flagged rather than silently written." 📘 That is our own hard-cap model (24 / 60 / 100) implemented with the behaviour we specified commercially.familiesFor(acc)uses the INDUSTRY to imply a family the account is not on yet — "a dealer sitting on a generic plan should be shown dealership tiers, and never a restaurant one." ⚠ That is the exact case a custom deal is written for, and I had not specified it.
✅ Discipline: 1,296 tokens, 0 hardcoded hex, 0 native <select>, 0 em or en dashes in visible copy, 36 aria-label. ✅ And persona toggles are still correctly absent — zero occurrences of any persona name, with the roles step still rendering the absent-store treatment. The design did not invent a role model to fill a gap, twice in a row.
⚠ What this sign-off does NOT cover, stated so it is not over-read
🔎 I validated the MODEL, not the visual design. A script cannot see wrong spacing, a weak information hierarchy, or a drill-down that is technically complete and hard to read. 📘 That is the permanent limit CLAUDE.md records for every automated gate. A screenshot of the Subscribers list and one client record is the missing half of this review, and it is the only thing worth attaching.
🔎 The unplanned dividend: entitlements.js is now the SPEC for the table
📘 QRS-863 listed the subscription record, the usage ledger and RBAC as missing schema. The design round has produced a working reference implementation of the first two, and it is DOM-free and names its own lift target:
apps/web/src/platform/entitlements.tsoverentitlement_grants(scope, scope_ref, workflow_key, effect, limit_value, limit_period, on_exceed, set_by, set_by_user, set_at) with the resolution as a Postgres function so the API and the UI can never disagree.
🔎 So the schema work no longer starts blank. Precedence-as-data, the three axes, on_exceed, the set_by authority chain, plan families, plan caps, the derived segment and the diff are all specified in executable form. ⚠ The one thing that must NOT be ported literally is the per-row resolver (QRS-866): the list view needs a set-returning query.
Confirmed compliant (do not re-flag)
- Token discipline: 764
var(--token)references, only fallback-pattern hex values, 0 raw hardcoded colors. - No native
<select>anywhere. - Sidebar nav (11
<a href>links) is consistent with the shared admin shell used by Templates. - The one em-dash-looking character found is a data placeholder (
'—'for "no value" in a table cell), not prose copy — not a real violation of the no-em-dash rule.