Design Rationale — Admin
Non-obvious design choices for the admin module — the WHY behind each decision.
Admin (locked 2026-06-10)
Configuration-only scope — the "admin grab-bag" was cut to its coherent core
Decision: admin owns tenant configuration only: business profile, branding, key-value settings, hardware devices, integration configs, webhook configs, API keys, and approval workflow definitions. Nothing else.
Why: The original module spec was a grab-bag (config + HR scheduling + internal comms + auth policy + approval workflows). Scoping to "tenant configuration" is the coherent core; everything else was cut or handed to the module that actually owns it.
Guard: Do not let Admin re-absorb HR/labor, internal communications, or authentication policy. Those are permanently out-of-scope or owned elsewhere (Identity owns auth policy; HR is out of Vrida entirely).
HR/labor/scheduling is permanently out of Vrida — not deferred, not future
Decision: No staff scheduling, time-clock, shift, or payroll tables anywhere in any module, ever.
Why: Vrida is a retail transaction ERP. Staff management is a separate market solved by dedicated tools (Gusto, ADP, Homebase, Deputy). Adding it would bloat scope without differentiating Vrida. The only "staff" data in Vrida is identity_user records plus actor-stamp columns on operational tables.
Guard: HR is permanently out — do not build it, do not "defer" it to a future version, do not stub it. If a feature request requires shift tables, it belongs in an integration with a workforce tool, not in Vrida schema.
Tax calculation is Stripe Tax — no rate/jurisdiction tables in Admin
Decision: Admin has no tax_jurisdiction, tax_rate, tax_holiday, or tax_category tables. Stripe Tax owns all rate lookup and calculation.
Why: Maintaining tax rate tables is a perpetual stale-data problem (rates change, nexus rules change, jurisdictions change). Stripe Tax solves this as a managed service — the same principle as "POS doesn't process cards, Stripe does." Vrida's tax surface is exactly three points: inventory.item.stripe_tax_code (product classification), crm.customer_tax_certificate (customer exemption evidence), and admin.compliance_document (the tenant's own tax permits/registrations).
Guard: Do NOT build a tax-rate engine, even for "simple" cases. The three-point model is the complete design. Any tax feature request should be mapped to one of these three existing surfaces or implemented via Stripe Tax configuration.
Security policy config lives in Identity, not Admin; Admin is the UI
Decision: password_policy, sso_provider, session_config, and ip_whitelist tables live in identity. Admin is the configuration UI that writes these via IdentityService.
Why: Auth config belongs with auth ownership — the module that enforces a policy should own its configuration table. Admin being the UI surface does not make it the data owner.
Guard: Do not create admin.password_policy or similar. Admin calls IdentityService; the config rows live in identity.
tenant_setting: one generic key-value catch-all with nullable site_id override
Decision: A single tenant_setting table (category + key + value JSONB + nullable site_id) absorbs all deferred per-module config (POS terminal behavior, Orders defaults, Billing terms defaults, etc.), rather than a typed config table per module.
Why: Most configuration is key-value pairs that differ by tenant and optionally by site. The one config concern that could have demanded a typed table (tax) was resolved by Stripe Tax, leaving no case for per-module typed config tables.
Guard: The nullable site_id override pattern requires two partial unique indexes — UNIQUE (tenant_id, category, key) WHERE site_id IS NULL (tenant-wide default) and UNIQUE (tenant_id, category, key, site_id) WHERE site_id IS NOT NULL (site override). A single multi-column unique with nullable site_id does NOT block duplicate tenant-wide settings (NULL != NULL). This is the recurring NULL-in-unique trap — applied here exactly as in customer_tax_certificate and payment_intent.idempotency_key.
api_key is Admin-owned tenant integration access; token stored as hash only
Decision: api_key lives in admin (not identity). The raw token is shown once at creation and never stored; only token_hash (SHA-256) persists.
Why: Tenant API keys are integration configuration — they grant programmatic access to the tenant's Vrida account for webhooks, integrations, and external tooling. This is an admin concern, not a user-auth concern. The hashing discipline (raw once, hash always) matches identity's token handling.
Guard: api_key and identity's auth tokens are different concerns — do not conflate them. api_key is for tenant-to-Vrida API access; identity handles user login sessions. Both use the same hashing safety rule.
Approval engine: workflow tables configure routing; operational tables record outcome
Decision: approval_workflow, approval_routing_rule, and approval_request are configuration + routing — they define rules and track pending approvals. The locked operational tables (pos.sale.discount_approved_by, purchasing.purchase_order.approved_by_user_id) record the outcome directly on the source record.
Why: Approval is a configurable routing concern (who approves what, escalation paths); the operational result (who approved it, when) belongs on the record that was approved. Separating routing config from outcome keeps locked modules unchanged when approval rules evolve.
Guard: approval_request links to source records via source_module / source_type / source_ref (polymorphic UUID) — not an enforced FK into the locked operational tables. Do not add enforced FKs from approval_request into pos or purchasing schemas. The link is intentionally loose.