Appearance
QRSETU platform data architecture
Status: DESIGN, awaiting owner approval. Nothing here is built. Authored 2026-08-07 on the owner's directive that
qr-setu-devis the source of truth and a greenfield project, with standing authority to redesign any backend artifact from scratch. Production is disposable — the owner will replace it by replicating Dev — so this design carries no cutover, no data migration, and no legacy compatibility obligation.Companion decision record: ADR-0020 · Standing rule:
CLAUDE.md§ "Second rule: Dev is the source of truth".
1. What is being replaced, and why
The subject of this redesign is a file, not an environment: supabase/migrations/20260710134136_baseline_schema_from_prod.sql.
That squash — 58 tables, 705 columns, 45 of them on profiles alone — was lifted verbatim from a production database built before the platform had an architectural direction. It was captured as a migration in July 2026 to get the schema under version control, which was the right move for traceability and the wrong one for design: from that moment it became the implicit reference for every schema decision taken afterwards. The standards programme has been retrofitting around it ever since. ADR-0009 added a capability layer beside ADR-0007's untouched entitlement tables; ADR-0014 built an entire least-privilege apparatus to police one public projection; ADR-0016 replaced the reminders schema outright after finding it could not express a recurring reminder.
Dev carries that same baseline. So "greenfield Dev" has a concrete meaning: the baseline squash and the migration series built on it are archived, and a new baseline is authored from this design. Dev is rebuilt, not patched.
Nothing about the design below is derived from, constrained by, or justified with reference to what any environment currently contains. The faults in §2 are properties of the baseline file, which is the artifact being deleted — cited so the new model can be checked against the problems it is required to make impossible, not as an inventory to preserve.
2. The five structural faults being designed out
Each of these forces a workaround that exists in the codebase today. They are the specification for what the new model must make impossible, not merely better.
F1 — profiles conflates five concepts in 45 columns
It is simultaneously the auth identity, the business identity, the public card's content, the merchant's private preferences, and the billing subject. Consequences already visible:
- Enterprise workspaces are unrepresentable, because a business and a person are the same row. ADR-0001's "org is a billing wrapper over independent profiles" is a retrofit forced by this.
- A settings change and a public-card change are the same write, so cache invalidation cannot be scoped and every profile write must purge the public card.
- The owner's own paying-≠-owning insight (a dealership owns its rep's card; an MLM upline pays for a distributor's card but does not own it) has nowhere to live.
F2 — the public surface reads the private table
get_public_profile_by_slug projects a hand-chosen subset of those 45 columns. The entire least-privilege apparatus exists to police that one projection: ADR-0014, check:sql, anon_least_privilege_test.sql with its 32 assertions and a hand-computed "81 residual" constant, profiles_anon_exposure_test.sql, and a standing rule that attributes/metadata must never reach the anon surface. Every new public field is a fresh opportunity to leak email, mobile_number, gstin or a future pan.
A schema in which the public table contains only publishable data needs none of that. The question stops being "did we remember to exclude PII from this projection?" and becomes "is this column in the public table?" — which a reviewer answers by looking, and a grant enforces absolutely.
F3 — two competing, unwired feature mechanisms
| Mechanism | Origin | State |
|---|---|---|
capabilities · archetype_default_capabilities · profile_capabilities · get_my_capabilities() | ADR-0009 | Wired to the app on 2026-08-06 (S1); archetype scope only |
platform_features · domain_features · user_feature_permissions + 8 functions | ADR-0007 | Zero call sites anywhere in apps/, packages/, supabase/functions/ — verified by grep, not inferred |
Two registries, two override chains, no resolver combining them, and no representation at all for plan, enterprise workspace, vendor group, or beta cohort. This is precisely why the owner's six-scope Access Control requirement has no home today.
F4 — commerce is modelled three times
profile_items · the seven-table digital_menu_* cluster (_items, _variants, _images, _revisions, _favorites, _categories, digital_menus) · bio_links. Three catalogues for one concept, each with its own RLS, its own media handling and its own availability semantics.
F5 — money and audit are absent or bolted on
No ledger. updated_at columns where an append-only event stream is required. Soft-delete columns on configuration tables, which every RLS policy must remember to filter — a footgun that fails open. idempotency_keys.user_id is NOT NULL REFERENCES auth.users(id), which makes an idempotency ledger for an anonymous buyer's order structurally impossible — the exact write path 26.0.1 is built around.
3. Design principles
These are the load-bearing decisions. Everything in §4 follows from them.
P1 — The PRINCIPAL is a user; a WORKSPACE is the business tenant. Membership count is what distinguishes the three user categories.
| Category | Workspace memberships |
|---|---|
| 3 · Individual / consumer | 0 |
| 1 · Solo business owner | 1 (member-owned) |
| 2 · Enterprise employee | 1 (org-owned, governed by the org admin) |
Not "profiles, with organizations bolted on later" — that is the retrofit ADR-0001 was forced into. And not "workspace is the primary tenant" either, which was this document's first draft and could not serve consumers at all: any resolver, RPC or policy that requires a workspace to answer a question is structurally unable to serve category 3. A solo merchant is a one-member workspace, so enterprise costs nothing structurally; a consumer is a principal with no workspace, so the consumer product costs nothing structurally either.
users.primary_context (business | individual) selects the default experience — the entry route and the dashboard — and is deliberately not an exclusion. A consumer who later starts a business gains a workspace membership. Never a second account, never a data migration.
⚠ primary_context is a PREFERENCE and must never be a security input. The authoritative answer to "does this person have a business?" is derived from workspace membership, always. A user-mutable column that grants access is a privilege-escalation primitive — flip your own preference, gain grants — and storing a derivable fact is the duplicate-source-of-truth defect (QRS-249/284/287). It was named account_type in this document's 2026-08-07 first draft; renamed for exactly this reason.
The transition is a pure INSERT, and that is the acceptance test. An individual becoming a business inserts one workspaces row and one workspace_members row. If it ever requires an UPDATE to anything consumer-side, a row move, or a data migration, the model is wrong. Checkable in pgTAP, and it fails loudly if someone later couples the two identities. Full flow: user ecosystem.
P2 — The public surface is its own table, never a projection of a private one.cards holds only publishable data. anon receives SELECT on cards (and its content children) and on nothing else, ever. F2's entire apparatus becomes unnecessary rather than better-tested. This is also what makes anonymous-first consumer access (category 3) a property of the schema rather than a policy someone must remember: a scanned card is fully usable with no account because the table it reads contains nothing that needs one.
P2a — No policy grants access by ROLE alone, because authenticated is about to mean "the logged-in general public." Until category 3 exists, authenticated ≈ merchants ≈ a small semi-trusted population, which is the population ADR-0014 reasons about. Consumers will outnumber merchants by orders of magnitude and are equally authenticated. So every merchant-data policy scopes by relationship — workspace_id in (select workspace_id from workspace_members where user_id = auth.uid()) — and using (true) TO authenticated is banned outright. pgTAP must assert that a consumer (authenticated, no workspace) reads nothing merchant-owned. This is a threat-model change, not a style preference.
P3 — One feature registry, three resolution axes, eight grant scopes, one subject-polymorphic resolver. The axes answer genuinely different questions and must not be collapsed:
| Axis | Question | Sourced from |
|---|---|---|
| Applicability | Does this workflow make sense for this subject at all? | archetype · industry · workspace |
| Entitlement | Is it unlocked, and how much of it? | plan · group · workspace · workspace_member · user |
| Availability | Is the code shipped and switched on? | platform · group (beta cohort) · user (individual beta) |
There is deliberately NO account_type scope, though the 2026-08-07 first draft added one. Consumer features are granted at platform scope — merchants are consumers too, so they should see discovery as well — and business features are not applicable without a workspace, which the applicability axis already answers. A scope resolving on a derived fact would duplicate what membership already states.
effective = applicable AND entitled AND available. One table holds every grant on every axis at every scope, so the Access Control matrix is a query rather than five integrations.
The resolver takes a SUBJECT, not a workspace — because a consumer has no workspace and still needs features resolved (marketplace discovery, order tracking, consumer-held subscriptions). Resolving for a bare user is the ordinary case for category 3, not a fallback.
P4 — Typed columns for what the platform reasons about; validated JSON for what it only stores and renders. The line is not "how variable is it" but "does platform code branch on it?" Anything filtered, sorted, grouped, charted, priced, or gated is a real column (ADR-0010). Archetype-specific display fields ({servings, prep_time}, {grades, duration}) are attributes jsonb, validated against a JSON Schema the archetype itself declares.
P5 — Money is integer minor units; every financial or authorization-relevant mutation has an append-only event companion. Never numeric rupees. Never an audit column where a ledger is required.
P6 — No soft delete. A deleted_at column that 93 policies must each remember to filter fails open. Use a status enum where status is a genuine domain concept, and an archive table where history must be retained.
P7 — Extension points are declared, not discovered. An archetype declares its item attribute schema. A feature declares its dependencies, its risk tier, and whether it is universal. A module registers itself. Adding a vertical, a feature, or a plan is therefore data, and the "new vertical = config, not code" promise of ADR-0009 extends to the whole platform.
P8 — Stable text keys for platform reference data, UUIDs for tenant rows.industries.key = 'salon', not business_domain_id = 14. Integer ids for data promoted across environments produced QRS-249 (hardcoded ids disagreeing with the database) and forced the standing rule "ids 11-22, PINNED explicitly, never nextval." A text key is self-describing in every log line, every jsonb payload, and every environment, and it makes that entire hazard class unrepresentable.
4. The model
Layer 1 — Identity & tenancy
| Table | Purpose | Notes that matter |
|---|---|---|
users | 1:1 mirror of auth.users. The principal. Carries primary_context (business | individual) — a PREFERENCE selecting the default entry route and dashboard, never a security input. | Named users, not identities — Supabase already owns auth.identities (OAuth provider links) and the collision would be actively misleading. A consumer is a users row with zero workspace_members rows, which is why nothing consumer-facing may require a workspace. |
workspaces | The business. Archetype, industry, legal identity, private business detail. | kind (solo|organization) is shape, not a different table. ownership_model (member_owned|org_owned) is the column that resolves paying-≠-owning: on seat revocation, org_owned suspends the workspace, member_owned drops it to the free plan and keeps its card. |
organizations | The enterprise tenant: centralized billing subject, employee roster, org-level policy. A workspaces row may belong to one. | Category 2 only. An org admin governs its members' archetype, seats and policy — which is why archetype immutability below is governor-scoped, not absolute. |
Archetype immutability is subject-scoped, and a plain write-once trigger is the wrong guard. A category-1 owner cannot change their own archetype_key/industry_key after onboarding. A category-2 employee's is assigned and mutable by their org admin, per the owner's explicit requirement that "individual employees don't independently decide their business type or subscription." So the trigger is: immutable to the subject; mutable by an authorized governor (a platform admin, or the org admin of the owning organization) via a SECURITY DEFINER RPC that writes an audit_log row. This corrects the plan's S-D4, which specified an unqualified write-once trigger and would have made enterprise employee management impossible. | workspace_members | The seat occupancy: (workspace_id, user_id, role_key, status). | Composite PK. A solo merchant is one row. | | roles · permissions · role_permissions | RBAC registry per RBAC.dc.html — 9 actions, risk tiers, dependency map. | Deny-by-default: absent = denied. roles.scope (platform \| organization) is load-bearing: a platform admin is QRSETU staff over all tenants; an organization admin is a customer's employee, powerful inside one org and powerless outside it. Conflating them is privilege escalation, and ADR-0006's platform-role-vs-tenant-role split is the governing decision. |
workspaces deliberately does not hold the slug or any public content. Both live in cards.
Layer 2 — The public surface
| Table | Purpose |
|---|---|
cards | The public Setu Card. slug citext UNIQUE (write-once trigger), status, publishable identity, template_key/template_version/palette_key, coarse locality for SEO. |
card_links | Ordered outbound links (replaces bio_links). |
card_hours | Opening hours by weekday. |
The duplication between cards and workspaces is the design, not denormalization — and this is the point most likely to be misread. cards.display_name is not a copy of workspaces.display_name; it is the name the merchant chose to publish, which may legitimately differ from their legal or internal name. cards.public_phone is not workspaces.contact_phone; one is publishable by intent, the other is account contact. Storing them in one column is the bug — it is what makes every public read a leak risk. These are two different facts about two different audiences.
cards has an FK to workspaces (not the reverse), so a workspace holding several cards later — a dealership's corporate card plus rep cards — is additive, not a migration.
No card_sections table. The template manifest (ADR-0019) declares layout; these tables hold content; a block whose data is absent hides. Two sources of layout would contradict ADR-0019's T12 invariant, so the absence is deliberate.
Layer 3 — Taxonomy & extensibility
| Table | Purpose |
|---|---|
archetypes | key text PK, primary_entity, item_attribute_schema jsonb (the JSON Schema validating catalog_items.attributes), status. |
industries | key text PK, archetype_key FK, display metadata, status (public|private|retired), compliance_profile jsonb. |
compliance_profile is the column ADR-0004 needs and does not have. ad_restricted for doctor/CA/loan-agent verticals currently exists only as written intent, which is why resolvePromo must fail closed forever. Declaring it here retires that permanent limitation.
Layer 4 — Feature resolution (the backbone)
| Table | Purpose |
|---|---|
features | The registry. key text PK, parent_key (hierarchy), category, applicability (universal|scoped), depends_on text[], risk_tier, status (active|beta|deprecated|retired), min_app_build. |
feature_grant_scopes | The eight scopes and their precedence, as data: platform · archetype · industry · plan · workspace_group · workspace · workspace_member · user, each with a unique precedence int. |
feature_grants | One grant. feature_key, axis (applicability|entitlement|availability), effect (grant|deny), limit_value/limit_period, effective_from/effective_until, reason NOT NULL. |
workspace_groups · workspace_group_members | Cohorts: kind (enterprise|beta|cohort|custom). One primitive serves enterprise grouping, beta rollout, and custom vendor groups. |
Three decisions inside this layer carry most of its value:
applicability = universal replaces the CORE_FEATURES constant in code with a registry row. Settings, profile, the Setu Card and QR tools are universal — gating them produces a merchant locked out of their own account. Holding that in a table rather than a TypeScript constant makes it admin-visible, auditable, and impossible for a screen to decide for itself.
Scope precedence is a table, not a CASE expression. Adding an eighth scope (region, say) becomes a data change plus one FK column, not a resolver rewrite — and the precedence is readable by the admin panel, which is exactly what rendering the Access Control matrix requires.
Polymorphic scope is modelled as exactly-one-non-null FK columns, not a scope_id text + validation trigger. archetype_key, industry_key, plan_key, group_id, workspace_id, member_user_id, all nullable, with a CHECK asserting the populated one matches scope_kind (and that platform populates none). This buys real referential integrity from Postgres instead of a trigger that must itself be tested — a correction to the trigger-based design drafted on 2026-08-06.
The resolver returns provenance, not just booleans:
resolve_features(p_user_id uuid, p_workspace_id uuid default null)
→ table(feature_key text, enabled boolean,
limit_value int, limit_period text, used int,
applicability_source text, entitlement_source text, availability_source text)The user is the required argument and the workspace is optional — that parameter order is the design, not a convenience. Called with a workspace, it answers for a merchant in a business context. Called without one, it answers for a consumer (category 3): marketplace discovery, order tracking, consumer-held subscriptions. A workspace-first signature — which this document's first draft had — cannot express a consumer at all.
Three source columns mean support and the admin panel answer "why is this off for this user?" in one query. That is the difference between a control plane and a pile of toggles.
Layer 5 — Commerce catalogue
| Table | Purpose |
|---|---|
catalog_items | One catalogue. kind (product|service|package|menu_item|course|listing), price_minor bigint, compare_at_price_minor, availability (4-value enum), track_inventory bool + stock_quantity int NULL, position, attributes jsonb, status (active|archived). |
catalog_categories | UNIQUE (workspace_id, slug) — structurally prevents "Sweets"/"sweets"/"Sweet " becoming three categories. |
catalog_item_variants | Table ships now, UI does not. The legacy menu already needed variants; designing it now is free and avoids an R2 migration. |
catalog_item_media | (item_id, media_id, position, alt_text). |
media | One media table for the whole platform. bucket (media|private), storage_key UNIQUE, status (pending|ready|failed), content_type, byte_size, dimensions, checksum, purpose. |
Discount percentage is derived from compare_at_price_minor and price_minor by a pure function in @qrsetu/domain, never stored — a third column would disagree with the two prices the first time either is edited.
A single media table (rather than logo_url text scattered across tables) means one orphan sweeper, one CDN URL derivation, one delete path, and one place to enforce a per-workspace storage quota. status pending → ready is the two-phase upload the presigned-R2 path requires.
Layer 6 — Money
| Table | Purpose |
|---|---|
orders | status state machine, subtotal_minor/discount_minor/total_minor, commission_minor/vendor_minor with CHECK (total = vendor + commission), idempotency_key UNIQUE, and both buyer forms: buyer_name/buyer_phone/buyer_email plus buyer_user_id uuid NULL — null = anonymous, set = a registered category-3 consumer. |
order_items | Snapshots name, price_minor and quantity at purchase. |
payments · payment_events | Provider payments; append-only webhook ledger with provider_event_id UNIQUE. |
payout_accounts · payout_account_events | activation_status and account_status as separate columns, requirements jsonb, ifsc/account_last4/account_hash, last_verified_at. Never the full account number. |
transfers | Route splits, per order. |
order_items snapshots the price, because joining to catalog_items for an invoice line means a historical invoice changes when the merchant edits the item. This is the most common commerce schema defect and it is unrecoverable once orders exist.
Two status columns on payout_accounts because Razorpay's account status is only created|suspended while the product activation_status is what actually gates transfers — a state machine mirroring the former reads healthy while payouts are blocked.
Layer 7 — Subscriptions, licensing & seats
| Table | Purpose |
|---|---|
plans | key text PK (one tier vocabulary, platform-wide), rank int, billing_period, price_minor, status. |
billing_accounts | Who pays. subject_kind (user|workspace) + subject FK, provider_customer_id. |
subscriptions | billing_account_id, plan_key, lifecycle status, period bounds, provider_subscription_id. |
seats | subscription_id → workspace_id (+ optional member_user_id), status (assigned|revoked). |
subscription_events | Append-only. |
usage_counters | (workspace_id, feature_key, period_start) → used int. Quota enforcement. |
plans has no features jsonb and no limits jsonb, and that is the most important line in this layer. The current subscription_tiers.features/limits is a second feature mechanism. Here a plan's features are simply feature_grants WHERE scope_kind = 'plan' AND plan_key = 'pro'. One mechanism, one admin screen, one resolver, one audit trail — and every cell of the owner's Access Control matrix, at every one of the seven scopes, is a row in the same table.
seats is what makes group subscriptions work, and it needs no special cases. A dealership: one billing_accounts(subject_kind='workspace'), one subscription, N seats pointing at reps' org_owned workspaces. An MLM upline: one billing_accounts(subject_kind='user'), one subscription, N seats pointing at distributors' member_owned workspaces. Identical rows; ownership_model alone decides what revocation does.
Layer 8 — Analytics
| Table | Purpose |
|---|---|
analytics_events | Append-only, declaratively partitioned by month. workspace_id, card_id, group_id, event_name, occurred_at, source, props jsonb. No IP, no cookie, no fingerprint, no visitor identifier (D7). |
analytics_daily | Rollup: (workspace_id, day, metric) → value numeric. |
Partitioning is declared on day one because this is the only table that grows without bound; adding partitioning to a populated append-only table later is a rewrite.
The rollup is deliberately narrow (metric, value) rather than wide (total_views, total_clicks, …). ADR-0010's typed-column rule governs the event table, where filtering and grouping happen. For a rollup, narrow means a new metric is zero DDL and every chart query has one shape.
Layer 9 — Platform operations
| Table | Purpose |
|---|---|
audit_log | Append-only. actor_user_id, actor_role, action, scope_kind/scope_id, target_table/target_id, before/after jsonb, reason, occurred_at. |
idempotency_keys | key PK, scope, workspace_id NULL, user_id NULL, request_hash, response jsonb, expires_at. |
outbox | Transactional outbox: topic, payload jsonb, status, attempts, next_attempt_at. |
reserved_slugs | Retained unchanged — already seeded with 753 entries (the plan's "8 words" note is stale). |
feature_flags | Retained from 20260806160000; folded into the availability axis as scope_kind='platform'. |
Nullable user_id on idempotency_keys fixes F5 by construction: an anonymous buyer's order can have an idempotency ledger entry, which the current NOT NULL REFERENCES auth.users(id) makes impossible.
The outbox is the answer to G1/G2, and it solves three problems with one table. Today no write path invalidates the Cloudflare cache, and an EF making a best-effort HTTP purge inside a request will silently lose purges. An outbox writes the business row and the purge intent in one transaction; a worker drains it with retries and dead-lettering. The same table carries webhook deliveries and transactional email, so "vendor edits card → public card updates" becomes a guarantee rather than best effort.
5. What the design removes
| Removed | Because |
|---|---|
anon_least_privilege_test.sql's hand-computed residual constant, and the projection allow-list discipline | anon reads cards, which contains no private data. P2. |
platform_features · domain_features · user_feature_permissions + 8 unused functions | One registry, one grant table, one resolver. P3. |
capabilities · archetype_default_capabilities · profile_capabilities | Same. Folded into the applicability axis. |
subscription_tiers.features/limits | Plan features are grants at scope_kind='plan'. |
profile_items · the 7 digital_menu_* tables · bio_links | One catalog_items. F4. |
domain_features.deleted_at and every other soft-delete column | P6. |
Integer business_domain_id and the "pin ids explicitly" rule | Text keys. P8. |
profiles, all 45 columns | Split across users, workspaces, cards. F1. |
6. How this reaches Dev: re-baseline, not migrate
There is no cutover and no data migration. Dev is greenfield by owner directive, and designing a migration path would be inventing a constraint nobody has.
- Archive the series.
20260710134136_baseline_schema_from_prod.sqland every migration built on it move tosupabase/migrations/_archive_pre_v2/with a README recording what they were and why they are superseded — the same pattern_archive_pre_baseline/already established. - Author a new baseline from this design, as an ordered set (one file per layer, so each is one reviewable Change Record and one
check:releaseentry, rather than a 3,000-line monolith). - Rebuild Dev.
drop schema public cascade; create schema public;then apply the new series.auth.*is untouched, so existing Dev sign-ins keep working; their orphaned app rows are gone, which on a greenfield Dev is the intent. - Re-seed reference data from the repo — archetypes, industries, features, plans, roles, reserved slugs. All of it is repo-authored, so this is deterministic and re-runnable, and it is what makes "new vertical = config" real rather than aspirational.
- Verify, then keep going.
npm run test:db(pgTAP),check:sql,get_advisors,generate_typescript_types, and one live functional probe per public surface.list_migrationsimmediately after the firstapply_migration— MCP stamps its own version rather than the filename's (QRS-267).
Production is out of scope for this work entirely. It is replaced by replicating Dev, when the owner decides Dev is release-ready, as a separate exercise they initiate.
A retained pattern worth naming: the new tables keep the legacy schema convention from 20260805090000_archive_dummy_and_dead_rows.sql for future archival needs (no IF EXISTS, RESTRICT not CASCADE, COMMENT ON every archive table). Nothing is archived into it now — there is nothing on a greenfield Dev worth archiving.
7. Deliberately not in this design
Named so they can be refused later rather than re-argued:
- Multi-currency.
currency char(3) CHECK (= 'INR')throughout. The column exists so the check is the only thing that changes. - Variant UI, though
catalog_item_variantsships. - Region as an eighth grant scope. The precedence table makes it additive.
- Multiple cards per workspace. FK direction permits it; no UI, no resolver.
Buyer accounts. Orders carry buyer contact, not aREVERSED 2026-08-07 — category 3 (individual consumers) makes registered buyers a first-class case, not a later addition.buyer_user_id.orders.buyer_user_id uuid NULLships now: adding an identity column to a high-volume transactional table later is expensive, and the consumer's own order-tracking view is unbuildable without it. Only the consumer UI is deferred.- Row-level analytics identity. Permanently excluded by D7, not deferred.
- Marketplace/discovery search.
cards.search_vectoris declared and unused. - The consumer (category-3) product surface — dashboard, discovery, browsing, recommendations, promotions, consumer-held subscription management. The identity and its transactional hooks (
users.primary_context,orders.buyer_user_id,user-scoped feature grants) ship now; the screens do not. That split is deliberate: identity retrofitted onto append-only and high-volume tables is expensive, whereas screens are additive at any time. - Chat, bookings and consultations between a consumer and a vendor. Named here because they are the workflows that force consumer registration, so they define what the identity must support.
- The enterprise org-admin portal. ⚠ This is a surface ADR-0011 does not contain — Stack 1 currently enumerates public cards, landing and platform admin. A customer-facing tenant admin is a fourth tier with its own authz boundary, and it needs an ADR-0011 amendment before it is built, not after.
8. Open decisions this design needs from the owner
- The plan/tier vocabulary — now blocking.
plans.keyis one platform-wide vocabulary and every entitlement grant references it. Three vocabularies currently exist in the repo (Free/Pro/Enterpriseas stated verbally;free/starter/pro/businessintemplates.required_tier;profiles.subscription_tier's own enum). The resolver cannot be written against an undecided list. - Naming:
workspacesvsbusinesses.workspacesreads correctly for enterprise and oddly for a solo stall vendor;businessesis the reverse. The model is identical either way — this is a vocabulary decision that propagates into every table name, RPC name and screen, so it is worth five minutes now.
9. Cost, stated plainly
| Phase | Est. |
|---|---|
| This document + ADR-0020 + ER/HLD diagrams | 1.0d |
Baseline series: DDL, RLS, grants, COMMENT ON every object, triggers, reference-data seeds | 3.0d |
| pgTAP: RLS isolation, grant least-privilege, resolver precedence both directions, governor-scoped archetype trigger, consumer isolation (an authenticated user with zero memberships reads nothing merchant-owned — P2a) | 2.0d |
packages/schemas + packages/domain regenerated against the new model | 2.0d |
packages/data services rewired | 2.0d |
| Edge Functions rewritten onto the new tables | 2.0d |
Mobile screens + apps/web card route repointed | 1.5d |
| Dev rebuild + verification (pgTAP, advisors, live probes, three-surface pass) | 0.5d |
| Total | ~14.0d |
Greenfield Dev removes the data-migration step, which is the only line the earlier draft of this document had that no longer applies.
At the plan's stated 1.5× pace that is ~9 calendar days, and it precedes Sections C/D/E/F of the 26.0.1 plan (publish toggle, landing page, legal/abuse obligations, reconciler, money-health, Banking module) which are themselves outstanding.
Consequence, stated once: a first-principles redesign and a 15 Aug public launch are mutually exclusive. The recommendation is to do the redesign and move the launch — the alternative is shipping the first release on a schema already agreed to be wrong, and then owing an expand-contract programme bounded by requires_min_app_build from the first APK in a merchant's hands. Greenfield authority on Dev is worth exactly as much as it is used before that point.