Design Rationale — Consumer
Non-obvious design choices for the consumer module — the WHY behind each decision.
Consumer Module
First module of the Consumer Layer. Third non-tenant-scoped schema (after platform and shared). Locked 2026-06-11.
DR1 — Consumer-scoped RLS, not tenant-scoped
Decision: consumer.* tables use WHERE id = current_setting('app.current_consumer_id')::UUID (or WHERE consumer_id = ...) for RLS. tenant_id does NOT appear as an RLS scope in this schema. consumer_tenant_link.tenant_id is an FK dimension (which nursery), not the RLS predicate.
Why: Consumer is a platform-level person identity — one account across all nurseries. Scoping RLS on tenant_id would prevent a consumer from seeing their own cross-nursery links (which they own). It would also leak the shape of the implementation to tenants. Consumer identity must be isolated from tenant partitioning.
Guard: Never add tenant_id as an RLS predicate to any consumer.* table. The one table with tenant_id is consumer_tenant_link — it carries it as an FK dimension identifying which nursery a link belongs to, but the RLS policy still scopes on consumer_id. A ConsumerGuard / ConsumerInterceptor is required (analogous to TenantGuard) — do not reuse the tenant guard.
DR2 — Cross-store privacy: ConsumerService is the only access boundary
Decision: Tenants access consumer.* data ONLY through ConsumerService, which executes under SECURITY DEFINER or service-role. No direct tenant queries against consumer.* are ever permitted. A tenant can see only the consumer's name/email/phone (proxied) and its own relationship with that consumer — never another nursery's relationship or the consumer's cross-store history.
Why: A consumer's activity at Nursery B is legally and commercially private from Nursery A. Cross-store visibility belongs to the consumer alone (in the consumer app). Allowing direct tenant reads would leak cross-store data as easily as a table join — impossible to audit or prevent at query time.
Guard: Never add a read path from a tenant-scoped query directly to consumer.*. If a future feature seems to require cross-nursery consumer data in a tenant context, that is wrong by architecture. Route through ConsumerService and expose only what the tenant legitimately owns.
DR3 — No consumer_type, no is_business, no is_wholesale
Decision: consumer.consumer has no consumer_type, is_business, is_wholesale, or equivalent discriminator column. The same person can be a retail customer at Nursery A and a wholesale customer at Nursery B.
Why: Wholesale/business status is a per-nursery relationship property, not an identity property. It lives in crm.customer.customer_type (tenant-scoped), resolved through crm.customer_group (group code = 'wholesale'), and priced via pricing.price_list_assignment (assignment_scope = 'customer_group'). All of this is tenant-scoped and already locked.
Guard: Never add consumer_type or is_business/is_wholesale to consumer.consumer. If a pricing or access question requires knowing whether a consumer is wholesale, the answer lives in crm.customer (tenant-scoped), not in consumer.*.
DR4 — Identity-canonical when linked
Decision: When crm.customer.consumer_id is set, consumer.consumer is the canonical source of truth for display_name, email, and phone. Tenant services read these via ConsumerService — they do not copy fields down to crm.customer. For anonymous buyers (consumer_id IS NULL), crm.customer carries the only copy (unchanged).
Why: Copying consumer identity into crm.customer creates a two-source-of-truth problem: the consumer updates their name in the app and the CRM still shows the old value. Field drift is invisible and accumulates silently. Reading through ConsumerService guarantees freshness.
Rejected: A sync/copy job that periodically updates crm.customer fields from consumer.consumer. Sync lag creates drift windows; sync failures leave stale data indefinitely; rollback is impossible.
Guard: Do not add name/email/phone copy-down logic to CRMService, ConsumerService, or any migration. When a linked consumer's identity changes, crm.customer should not be updated — the next ConsumerService read returns the fresh value.
DR5 — consumer_tenant_link vs crm.customer.consumer_id: complementary views
Decision: Two complementary representations of the consumer ↔ nursery relationship: (1) consumer_tenant_link (consumer-scoped, consumer's view of which nurseries they follow/are linked to), and (2) crm.customer.consumer_id (tenant-scoped, nursery's view of its customer linked to a platform consumer account).
Why: Different use cases require different access patterns. A consumer browsing the app wants to see "my nurseries" — that's consumer_tenant_link queried by consumer_id. A nursery looking up a customer wants to know if they have a linked consumer account — that's crm.customer.consumer_id. Neither view alone is sufficient. consumer_tenant_link also supports app-follow before any purchase exists (a consumer follows a nursery without yet being a crm.customer).
Guard: Do not try to consolidate these into one table. They are in different schemas (consumer-scoped vs tenant-scoped), serve different audiences, and have different RLS predicates.
DR6 — Unclaimed stub lifecycle and email merge on claim
Decision: Cashier-created stubs have status = 'unclaimed' and auth_provider_sub = NULL. Multiple unclaimed stubs with the same email_normalized across different nurseries are INTENDED — they accumulate during the window before the consumer claims their account. The email partial-unique index is scoped to status = 'active' only. ConsumerService executes the merge when the consumer claims.
Why: Nursery A and Nursery B may both create a stub for the same person (same email) independently. Blocking the second creation via a global unique constraint would cause unnecessary failures at the POS. The merge-on-claim pattern resolves all stubs to the single authenticated identity without friction at creation time.
Guard: The PARTIAL UNIQUE (email_normalized) WHERE status = 'active' AND NOT NULL AND NOT deleted index is intentionally non-global. Do not make it global or remove the status condition. The active-requires-auth invariant (CHECK (auth_provider_sub IS NOT NULL OR status = 'unclaimed')) is DB-enforced and must not be removed — it prevents active accounts without auth.
DR7 — Third non-tenant-scoped schema
Decision: consumer is a structural peer of platform and shared — the third schema in Vrida that deliberately omits tenant_id as an RLS scope. Like platform (Vrida's control plane) and shared (seed-managed reference data), consumer exists above the tenant level.
Why: Consumer identity is a Vrida platform concept, not a per-nursery concept. Placing it in a tenant-scoped schema would require either (a) duplicating the identity row per nursery (data integrity nightmare) or (b) using a single row with a hack to bypass tenant RLS (security nightmare). A purpose-built non-tenant schema is the only clean answer.
Guard: Future consumer-layer modules (rewards, offers, consumer_app) must each evaluate whether they are platform-scoped (cross-nursery — e.g., platform-wide points aggregation) or tenant-scoped (per-nursery — e.g., per-nursery earning rules). Do not assume that belonging to the "consumer layer" means a module is automatically non-tenant-scoped. rewards earning rules are per-nursery; only the aggregation view may be platform-scoped.
Guard: ReportingService must NEVER issue UPDATE or DELETE against inventory_valuation_snapshot or period_close_snapshot. Correction path: create a new report_snapshot_run with triggered_by = 'manual' and the corrected as_of_date. Incorrect rows are retained for audit purposes; soft-delete via deleted_at only when explicitly requested.