platform — Module Spec
1. Purpose
Platform is Vrida's control plane — the foundational module that defines every tenant and governs Vrida's own SaaS business. It owns tenant identity and lifecycle, Vrida-side billing (subscriptions, invoices, payments, AI credits — Vrida's revenue from tenants), feature entitlements (the source of truth for what each tenant can access), legal agreements, onboarding, usage metering, and operator audit. Every other module's tenant_id references platform.tenant; platform depends on no other module.
Reopen history: schema-locked 2026-06-09; reopened 2026-06-30 (announcement/platform_setting skeleton tables, 21→23 tables); reopened 2026-07-06 (autonomy-first cross-module backfill, PROJECT_DECISIONS #19); reopened 2026-07-07, 4th time (tenant-identity absorption from v1's admin.tenant_business_profile, executing the Admin↔Platform ownership boundary rule — PROJECT_DECISIONS #34 governing rule / #35 this reopen's build record; §4 below); reopened 2026-07-08, 5th time (Remediation Plan Phase 1 — CHECK/RLS/REVOKE/trigger fixes only, no table/column count change — PROJECT_DECISIONS #37; see end of §14 below); reopened 2026-07-08, 6th time, same day (Remediation Plan Phase 2 — PK-generation-strategy change only, no table/column count change — PROJECT_DECISIONS #38; see end of §14 below); reopened 2026-07-08, 7th time, same day (Remediation Plan Phase 4 — 3 new tables (accounting_period, legal_entity, outbox), 23→26 tables, 408→440 cols — PROJECT_DECISIONS #40; see end of §14 below).
2. Ownership
Owns: tenant records and lifecycle; tenant identity (legal name, EIN, business type, addresses, DBAs, NAICS classification — the full identity surface, per PROJECT_DECISIONS #34/#35, see §4 below); business profiles and contacts; Vrida→tenant billing (billing accounts, subscriptions, invoices, payments, dunning, AI credit wallet); subscription tiers and per-tenant entitlements; Enterprise contracts; Vrida-issued promo codes; legal agreement versions and acceptances; onboarding/provisioning tasks; monthly usage summaries; the operator audit log.
Does NOT own:
- App users / authentication →
identity(platform stores business contacts, not login users) - Merchant payment processing (the tenant selling to its customers, via Stripe) →
payments. Platform owns the other direction: Vrida billing the tenant. - Site/location structure →
multi_loc(platform referencesprimary_site_idand meters site count; the site model lives in multi_loc) - Console RBAC, impersonation/support-access →
identity - Tenant technical/operational configuration (
api_key,integration_config,webhook_config,hardware_device,tenant_setting, the tenant-side approval engine) and presentational branding (tenant_branding,compliance_document) →admin. Platform owns tenant identity; Admin owns the tenant's own technical/operational config and referencesplatform.tenant/tenant_profilefor identity — it never duplicates it. This is the governing rule from PROJECT_DECISIONS #34.
Note: Vrida-side billing (Vrida charging the tenant) is platform's domain and lives in platform's own tables — distinct from the
paymentsmodule, which handles the tenant's merchant sales to its customers.
3. Layer & Dependencies
Layer: Foundation / service-layer. Migration: Phase 1 — platform migrates first; every other module FKs to platform.tenant.
Depends on: nothing (platform is the root).
Depended on by: every module (all tenant-scoped tables FK to platform.tenant).
Cross-phase FKs — 8 of 9 now wired (identity Phase 3 migration, 2026-06-29):
- 4 →
identity.actor(polymorphic actor — any actor type):tenant_entitlement.granted_by_user_id,tenant_setup_task.completed_by_user_id,tenant_data_lifecycle.requested_by_user_id,tenant_lifecycle_event.actor_user_id - 4 →
identity.identity_user(human-semantics records):tenant_contact.identity_user_id,agreement_acceptance.accepted_by_user_id,tenant_internal_activity.performed_by_user_id,operator_audit_log.operator_user_id - 1 still deferred →
multi_loc.site:tenant.primary_site_id— plain UUID until multi_loc migrates (Phase 4).
See schema doc's Cross-Phase FKs table and OPEN_ITEMS.
Autonomy-first backfill (2026-07-06): tenant_internal_activity.performed_by_user_id and operator_audit_log.operator_user_id were retargeted from identity.identity_user to identity.actor (agent-as-actor, part of the canonical pattern applied across platform/identity/shared/multi_loc — PROJECT_DECISIONS #19), and tenant_entitlement.granted_by_user_id was renamed to granted_by_actor_id. These are constraint/naming changes only — no net change to platform's dependency on identity, which this module has always had.
4. Tables
Platform owns 27 tables (up from 23 — see the Remediation Phase 4 and Header/Line Remediation reopens below) / 450 columns (up from 408). Full column-level detail in docs/database/schema_docs/platform.md — names and purpose only here.
Tenant identity & lifecycle: tenant (root record; everything FKs here), tenant_profile (extended business profile — now the single source of truth for tenant identity, see below), tenant_contact (named business contacts + CS state), tenant_lifecycle_event (append-only status-transition history), tenant_data_lifecycle (post-cancellation retention/deletion workflow), tenant_internal_activity (append-only Vrida CS/admin log).
Reopen 4 — Identity Absorption (2026-07-07): Executes the governing rule in PROJECT_DECISIONS #34 (Platform owns tenant identity; Admin owns tenant technical/operational config) — this reopen is PROJECT_DECISIONS #35. tenant_profile grew from 34 to 39 columns: +5 new (business_email, legal_address, mailing_address, business_classification_code, ein_ref) absorbed from v1's admin.tenant_business_profile (now dropped entirely — full 17-column fate mapping recorded in PROJECT_DECISIONS #34 Block 3, not restated here), and 1 retyped: trading_name (text) → dbas (JSONB array, NOT NULL DEFAULT '[]', bare string-array shape, e.g. ["Acme Garden Co","Rose Garden Nursery"] — pinned down before the migration was written; confirmed live immediately beforehand that all 12 existing rows had a null trading_name, so the retype carried zero data-loss risk). tenant_profile is now where legal_name/business_type/phone/website_url/business_email/support_email/ein_ref/legal_address/mailing_address/dbas/business_classification_code all live — the single source of truth for tenant identity; Admin references these via platform.tenant_profile, it never carries its own copy.
Two columns are deprecated in place (column-comment only, no DDL change — both remain present, readable, and writable): tax_id (superseded by ein_ref, a vault reference — tax_id was plain unencrypted text, a real security gap PROJECT_DECISIONS #34 §Why-2 called out); logo_url (superseded by admin.tenant_branding.logo_ref, once Admin's v2 build makes that table live). Neither is dropped yet — see OPEN_ITEMS for both DROP triggers.
Migration: packages/db/migrations/20260707110000_platform_reopen_identity_absorption.sql (hand-written, applied live, verified). Code cutover done and verified (typecheck + tests): apps/api/src/platform/platform.service.ts (updateTenantProfile() gained setters for all 5 new columns; provisionTenant() wraps a single tradingName input into a 1-element dbas array), apps/api/src/platform/dto/platform.dto.ts, packages/types/index.ts (TenantProfile.trading_name replaced by dbas: string[]), apps/web/admin/app/tenants/[id]/page.tsx (reads p.dbas.join(', ')). Tests: apps/api/src/platform/__tests__/platform-identity-absorption.spec.ts (10 new tests — dbas defaulting/wrapping/multi-element, all 5 new columns round-trip, deprecated columns still writable and carry their DEPRECATED column comments, live column-count sanity checks for both tenant_profile=39 and platform-wide=408); full apps/api suite 539/539 passing (up from 529 pre-reopen), zero regressions. See PROJECT_DECISIONS #35 for the full build record.
Vrida-side billing: billing_account (Vrida's billing relationship with the tenant), subscription (the tenant's Vrida plan + dunning state), subscription_invoice (invoices Vrida issues), subscription_invoice_line (per-line decomposition of an invoice's total — new 2026-07-10, see Header/Line Remediation Fix #1 below), payment (payments the tenant makes to Vrida), ai_credit_account (AI usage credit wallet — row only if tenant uses AI), ai_credit_transaction (append-only AI credit ledger).
Tiers & entitlements: tier_definition (tier catalog/pricing/caps — reference), tenant_entitlement (source of truth for feature access).
Contracts & promotions: contract (Enterprise sales-led contracts), promo_code (Vrida-issued discount codes — reference).
Legal: agreement_version (published legal-doc versions — reference), agreement_acceptance (immutable acceptance records).
Onboarding & usage: tenant_setup_task (provisioning + onboarding tracker), tenant_usage_summary (append-only monthly usage snapshots).
Operator audit: operator_audit_log (append-only, cross-tenant compliance log).
Skeleton tables (2026-06-30 — schema locked, no write endpoint yet): announcement (Vrida-authored broadcast messages, mixed-scope), platform_setting (Vrida-wide key-value config, reference table).
Futureproofing (2026-07-08, Remediation Phase 4): accounting_period (fiscal-period tracker with real overlap prevention), legal_entity (1:N legal entities per tenant), outbox (durable transactional-outbox event table, deliberately mutable).
Autonomy-first backfill (2026-07-06): as part of the cross-module canonical-pattern retrofit, operator_audit_log.operator_user_id and tenant_internal_activity.performed_by_user_id were retargeted from identity.identity_user to identity.actor (agent-as-actor — zero backfill risk, since identity_user.id is itself a shared-PK FK to actor.id). tenant_entitlement.granted_by_user_id was renamed to granted_by_actor_id and gained automation_source (its existing metadata jsonb column doubles as the decision-provenance carrier). promo_code gained automation_source. contract gained the full human-in-the-loop review seam (review_status/review_reason/reviewed_by_actor_id/reviewed_at). See PROJECT_DECISIONS #19 for the full canonical pattern and rationale.
Remediation Phase 4 (2026-07-08): +3 tables / +32 cols — 23 → 26 tables, 408 → 440 cols. All additive; zero DROP TABLE/DROP COLUMN anywhere in this phase. Full record in PROJECT_DECISIONS #40.
accounting_period(9 cols:id,tenant_id,period_start,period_end,status,closed_at,closed_by_actor_id,created_at,updated_at) — this codebase's first use of PostgresEXCLUDE USING gist(thebtree_gistextension was enabled specifically for it):excl_accounting_period_no_overlap EXCLUDE USING gist (tenant_id WITH =, daterange(period_start, period_end, '[]') WITH &&)prevents two overlapping periods for the same tenant while leaving different tenants' identical date ranges unaffected. A new shared trigger function,platform.flag_closed_period_business_date(), is FLAG-NOT-REJECT by design (setsreview_status='pending', never raises) — deliberately non-blocking because POS is offline-first and a genuine June-30 sale can sync in July after June's period has already closed; rejecting it would silently lose the sale. Consumed bypos.sale/pos.sale_refund(BEFORE INSERT OR UPDATE OF business_date) andpos.register_cash_entry(BEFORE INSERTonly, since Phase 1's own append-only trigger on that table already blocks every UPDATE unconditionally — an UPDATE clause there would be dead code).legal_entity(8 cols:id,tenant_id,name,ein_ref,is_primary,is_active,created_at,updated_at) — 1:N from tenant, so a tenant can incorporate a 2nd LLC without splitting into two Vrida tenants.legal_entity_tenant_id_primary_unique(partial uniqueWHERE is_primary=true) allows exactly one primary entity per tenant. Backfilled 1 row per existing tenant (1997 rows at build time). A nullableentity_idwas added to 10 header tables codebase-wide:platform.contract,platform.billing_account,admin.compliance_document,tax.tax_calculation,billing.ar_account,billing.vendor_payable,purchasing.vendor_invoice,purchasing.purchase_order,orders.order_header,pos.sale— deliberately header-only, never on line items or onpurchasing.vendor.outbox(13 cols:id,tenant_id,aggregate_type,aggregate_id,event_type,payload,status,attempts,last_attempted_at,delivered_at,error,created_at,updated_at) — a durable transactional-outbox event table, genuinely MUTABLE (not append-only) —gen_random_uuid()PK, notplatform.uuid_generate_v7(), since Phase 2's UUIDv7-for-append-only-ledgers rule doesn't apply to a table whosestatus/attempts/delivered_atare expected to change post-insert. No consumer/dispatcher service exists yet.
Also: platform.contract and platform.billing_account each gained entity_id (+1 col each), part of Item 15's 10-table rollout above.
5. Capabilities
- Tenant provisioning & lifecycle — create tenants; drive status through the lifecycle state machine (§ Lifecycle below); record every transition.
- Vrida SaaS billing — subscriptions, invoices, payments, AI credit wallet; Vrida's own revenue collection from tenants.
- Dunning — failed-payment retry/grace/suspension workflow for Vrida's SaaS fees (§ Dunning below).
- Entitlement resolution — answer "can this tenant access X, and how much?" from tier + add-ons + overrides + contract + beta (§ Entitlement below); the feature-gate authority for the whole system.
- Per-location billing — base tier includes N locations; meter and bill additional sites.
- AI credit management — prepaid credit wallet with grant/purchase/consumption, optional spend limit, per-call ledger.
- Legal compliance — publish agreement versions; capture immutable acceptances.
- Onboarding orchestration — track provisioning + business-onboarding tasks to go-live.
- Usage metering — monthly per-tenant rollups for caps and upgrade prompts.
- Operator audit — immutable record of privileged operator actions.
6. Service Contract — PlatformService
The public surface other modules and the admin pages call. No module reads platform's tables directly; everything goes through this service. Exact signatures emerge at build (Step 10); this defines the contract and the rules each area enforces.
- Tenant lifecycle — create/read tenants; status transitions (suspend, reactivate, cancel, reactivate-within-retention). Every transition is validated against the lifecycle state machine (§ below) and writes a
tenant_lifecycle_event. Illegal transitions are rejected. - Profile & contacts — read/update profile and contacts (mostly populated at signup).
- Subscription & billing — create/read subscription, change tier, pause/resume (seasonal), cancel; record invoices and payments; manage the AI credit wallet (grant, purchase, consume, balance/limit checks). Drives dunning state transitions.
- Entitlements —
resolveEntitlements(tenantId),hasEntitlement(tenantId, code), grant override, revoke, increment usage. Resolution follows the precedence and stacking rules (§ below). This is the feature-gate API the whole system depends on. - Tiers / promos / contracts — read tier catalog; validate promo codes; contract CRUD.
- Legal — get current agreement version; record acceptance (immutable); read acceptances.
- Onboarding / usage — read/update setup tasks; record/read usage summaries.
- Operator audit —
recordOperatorAction(...), called by privileged admin actions across the console. - Announcements / settings (skeleton, reads only) —
listAnnouncements(tenantId?)(mirrors the table's own mixed-scope RLS visibility rule),getSettings(). No write methods yet — see PROJECT_DECISIONS #15.
Registration note: the tenant signup flow (a downstream cross-module flow on www/tenant.vrida.app) is a primary caller — it populates platform via PlatformService (createTenant, profile, acceptance), not by direct table writes.
7. Admin Pages
Operator pages on admin.vrida.app (Vrida operators, not tenants). Five pages + a Work Queue; full page specs authored in docs/portal/ at build (Step 12).
- Tenants — per-tenant hub (overview, profile, contacts, lifecycle, onboarding, usage, data & retention, internal notes).
- Subscriptions & Billing — billing account, subscription, invoices, payments, AI credits, dunning.
- Plans & Entitlements — global tier catalog + per-tenant entitlement overrides.
- Contracts & Promotions — tenant contracts + global promo codes (main manual-entry surface).
- Legal & Agreements — global agreement versions + acceptance audit.
- Work Queue — cross-cutting needs-attention inbox (failed payments, trials ending, deletion requests, expiring contracts).
Page rules: operators perform actions that write audit records, never direct edits to system-owned data; global reference records appear on tenant pages only when they control signup/subscription/billing/legal/entitlement behavior; each section carries a scope label (global catalog / tenant assignment / tenant audit / tenant action).
REST API (built 2026-06-29): AdminTenantsController — all routes under GET /admin/tenants, guarded by AdminAuthGuard (Supabase JWT + is_platform_user=true). Read-only; pagination via ?limit= (default 50, max 200) + ?offset=. Write endpoints built at pages phase.
| Endpoint | Returns |
|---|---|
GET /admin/tenants |
paginated tenant list (filter by status/tier) |
GET /admin/tenants/:id |
tenant row |
GET /admin/tenants/:id/subscription |
active subscription |
GET /admin/tenants/:id/entitlements |
resolved entitlement set |
GET /admin/tenants/:id/profile |
tenant profile |
GET /admin/tenants/:id/onboarding |
onboarding task status |
GET /admin/tenants/:id/billing |
billing account |
REST API additions (2026-06-30, skeleton tables):
| Endpoint | Returns |
|---|---|
GET /admin/announcements |
all announcements (global + tenant-targeted), newest first |
GET /admin/settings |
all platform_setting key/value rows |
REST API additions (2026-07-08, admin console real-data cutover — PROJECT_DECISIONS #41): 16 new routes — 7 tenant-scoped (added to AdminTenantsController), 9 cross-tenant (3 new controllers, split out because they aren't scoped under a single tenant's :id). All guarded by the same AdminAuthGuard. No new tables/columns — every route reads columns that already existed.
Tenant-scoped — AdminTenantsController:
| Endpoint | Returns |
|---|---|
GET /admin/tenants/:id/contracts |
this tenant's negotiated contracts |
GET /admin/tenants/:id/agreement-acceptances |
this tenant's legal-agreement acceptance history |
GET /admin/tenants/:id/lifecycle-events |
this tenant's status-transition history |
GET /admin/tenants/:id/contacts |
this tenant's business contacts |
GET /admin/tenants/:id/usage |
this tenant's monthly usage snapshots |
GET /admin/tenants/:id/data-lifecycle |
this tenant's data export/deletion/legal-hold requests |
GET /admin/tenants/:id/internal-activity |
this tenant's internal activity log, performed_by_name resolved via join to identity_user |
Cross-tenant — AdminBillingController (/admin):
| Endpoint | Returns |
|---|---|
GET /admin/subscriptions |
every tenant's active subscription, mrr_cents computed from tier_definition pricing (excludes cancelled/ended for standard MRR semantics at the call site) |
GET /admin/invoices |
every tenant's invoices |
GET /admin/payments |
every tenant's payments against Vrida (Vrida's own revenue collection — distinct from payments.payment_intent, a tenant's own end-customer charges) |
GET /admin/dunning-queue |
tenants currently in a dunning state |
Cross-tenant — AdminCatalogController (/admin):
| Endpoint | Returns |
|---|---|
GET /admin/promotions |
all promo codes (global, not tenant-scoped) |
GET /admin/agreement-versions |
all legal agreement versions (ToS, MSA, Privacy Policy, DPA) |
Cross-tenant — AdminOpsController (/admin):
| Endpoint | Returns |
|---|---|
GET /admin/audit-log |
every privileged operator action across all tenants |
GET /admin/onboarding-pipeline |
every tenant currently mid-onboarding, with onboarding_step |
GET /admin/data-lifecycle-queue |
open (not completed/failed) data export/deletion requests across all tenants |
Console build (2026-06-30): A visual mockup for admin.vrida.app arrived with its own 5-group nav (Overview / Customers / Billing / Platform / System, 15 leaf pages) rather than this section's original 6-page grouping. The build adopted the mockup's structure as the visual system of record and reconciled the two: Subscriptions & Billing split into 3 separate pages (Subscriptions, Invoices, Payments); Plans & Entitlements split into 2 (Entitlements, Plans & pricing); Contracts & Promotions and Legal & Agreements slotted into Billing; Work Queue slotted into Overview; Support Access (not in this spec at all — an identity concern) slotted into System. Built at apps/web/admin, with Tenants (list/detail-Overview), Announcements, and Settings calling real endpoints (3 of 18 page groups, verified 2026-06-30) — everything else renders flagged 🟡 sample placeholders. See MODULE_BUILD_STATUS.md footnote ³ and OPEN_ITEMS for the full real/mock breakdown and open gaps. Reconciled 2026-06-30 (PROJECT_DECISIONS #14): Feature Flags page removed (redundant with Plans & Entitlements); tier display names set to Seed/Grow/Bloom for the real starter/pro/enterprise keys, no 4th tier.
Real-data cutover (2026-07-08, PROJECT_DECISIONS #41): all 7 tenant-detail tabs and 8 of the remaining cross-tenant list pages now call the routes above instead of mockData.ts — Contracts, Lifecycle, Contacts, Usage, Data & Retention, Internal Notes, Support Access (tenant-scoped tabs) and Onboarding, Subscriptions, Invoices, Payments, Promotions, Agreement Versions, Audit log, Support Access (cross-tenant pages). Explicitly still on mock, per an in-session decision: Plans & pricing, Entitlements catalog, Operators (role field — a genuine schema gap, see OPEN_ITEMS), Reports, and the top-level Usage page's daily-chart/trend-line portions. Dashboard aggregates (Total customers, Active tenants, Recent signups, Onboarding pending) are scoped to 5 flagship tenants (FLAGSHIP_TENANT_IDS in packages/types) rather than the full platform.tenant table — see PROJECT_DECISIONS #41 for why. Seed data for the 10 tables these routes read from (previously all empty) added via packages/db/seeds/admin-console-seed.ts (additive-only, idempotent).
8. Tenant Lifecycle State Machine
States: trial, active, past_due, suspended, cancelled, pending_deletion, deleted. Legal transitions (each writes a tenant_lifecycle_event):
| From | To | Trigger |
|---|---|---|
| (none) | trial | tenant signs up |
| trial | active | trial converts / paid signup |
| trial | suspended | abuse during trial |
| trial | cancelled | trial expires unconverted, or user cancels |
| active | past_due | a Vrida subscription payment fails |
| past_due | active | failed payment recovered |
| past_due | suspended | dunning exhausted, grace expired |
| active | suspended | manual suspension (abuse/ops) |
| suspended | active | reactivated |
| active / past_due / suspended | cancelled | customer cancels (or ops closes) |
| cancelled | active | reactivation within the retention window |
| cancelled | pending_deletion | retention window expires |
| pending_deletion | cancelled | ops halts a scheduled deletion (safety valve) |
| pending_deletion | deleted | data-wipe job completes |
Rules: deleted is terminal (post-deletion return = a new tenant). Seasonal pause is a subscription state (subscription.status='paused'), not a tenant state — the tenant stays active while seasonally paused, with a paused lifecycle event for the timeline. Dunning grace is tracked at the subscription layer, not as a tenant status.
9. Entitlement Resolution
Answers "can tenant T access feature X, and if metered, how much?"
- Active-window filter: an entitlement counts only if
status='active',is_enabled=true, and now() is within[starts_at, ends_at](null ends_at = no expiry). - Source precedence (when multiple active rows grant the same code):
override>contract>beta>addon>tier. Override is the manual exception lever and wins; tier is the baseline everyone gets. - Boolean vs metered: boolean entitlements grant yes/no access when present and active; metered entitlements carry a limit (
limit_value) and usage (used_value). - Stacking: metered add-ons stack on the tier base (tier 1000 + add-on 5000 = 6000 limit). An
overridereplaces the computed limit. - Default-deny: no active entitlement for a code = no access. Absence is never a silent grant.
- AI is two-layered: the AI entitlement answers "is this tenant allowed to use AI at all?"; the AI credit wallet (
ai_credit_account) governs usage and billing —use_creditsdraws down the prepaid balance (Vrida-granted + purchased) first;spend_limit_centscaps overage (null = uncapped); every AI call writes a consumption row toai_credit_transactionfor billing-dispute precision.
10. Dunning Workflow
Dunning is platform-owned — it is Vrida collecting its own SaaS fee from the tenant (distinct from the payments module, which handles the tenant's merchant sales). State lives on subscription (dunning_status: retrying / grace / suspended / recovered, plus stage and retry timestamps).
| dunning_status | Meaning | Drives |
|---|---|---|
| retrying | payment failed; retries scheduled | tenant → past_due (access continues) |
| grace | retries exhausted; final warned window | tenant stays past_due (access continues) |
| suspended | grace expired, still unpaid | tenant → suspended (access cut) |
| recovered | payment succeeded at any stage | tenant → active; dunning fields clear to null |
Rules: recovered is transient — on success, dunning fields clear to null and the recovery is recorded in tenant_lifecycle_event. The retry schedule is configuration, not schema. Dunning is AI-driven (adaptive retry timing, recovery messaging/prediction) and its execution depends on the Vrida-billing vendor (undecided) — see OPEN_ITEMS.
11. Data-Flow / Population Model
Most platform data is created by events, not operator entry. The admin pages are overwhelmingly monitor-and-act.
- Signup-fed: tenant, tenant_profile, first tenant_contact, agreement_acceptance, billing_account, subscription, tenant_setup_task — created by the tenant registration flow via PlatformService.
- Vrida-billing-fed: subscription state, subscription_invoice, payment, dunning, ai_credit_transaction (purchases) — from the Vrida-billing vendor's events.
- Job-fed: tenant_usage_summary, tenant_lifecycle_event, tenant_data_lifecycle, dunning progression — by background jobs.
- Tier-derived: most tenant_entitlement rows auto-derive from the tier; operators handle only exceptions.
- Genuine manual entry (operators/sales): contract, promo_code, tier_definition, entitlement overrides, internal notes.
12. Audit Routing
Three audit surfaces, each for a distinct purpose:
tenant_lifecycle_event— every tenant status transition (the churn/suspension source of truth).tenant_internal_activity— Vrida CS/admin actions on a tenant (notes, credits, trial extensions, feature grants) — per-tenant, append-only.operator_audit_log— every privileged operator action across the console — cross-tenant, immutable, compliance-grade (actor, role, target, before/after, IP). A single operator action may write both an internal_activity row (the business action) and an operator_audit_log row (the compliance record).
13. Cross-Module Seams
Platform's seams are cataloged in docs/modules/CROSS_MODULE_CONTRACTS.md (referenced, not restated). Key relationships: every module → platform.tenant; identity → platform (user FKs, support-access, RBAC); multi_loc → platform (site refs, location metering); ai → platform (AI credit enforcement/consumption via PlatformService); files → platform (contract/agreement document refs). Open cross-module dependencies are tracked in docs/open-items/OPEN_ITEMS.md.
14. AI Capability Discovery (Part D — 2026-07-06)
Platform previously only received the narrower "schema-translation" autonomy pass (2026-07-06 — agent-as-actor FKs, automation_source, review seams, decision_provenance/metadata-as-provenance across tenant_entitlement, promo_code, contract, operator_audit_log, tenant_internal_activity; see PROJECT_DECISIONS #19). This section runs the full AI_CAPABILITY_PLANE.md Part D module-walk (15 questions) that pass never got, grounded in the actual Drizzle schema at packages/db/src/schema/platform/*.ts.
D1–D15 Grid
| Question | Applies / Ruled out | Specific answer | Triggered items |
|---|---|---|---|
| D1. Capture targets | Applies | Platform is overwhelmingly event/job-fed (§11), not command-canvas-fed — there is no "type a sentence, get a transaction" capture bar here the way there might be in an order-entry module. The genuine capture targets are the operator-entry surfaces: create/edit contract (Enterprise deal), issue/deactivate promo_code, grant an entitlement override on tenant_entitlement, add an internal note or apply a credit (tenant_internal_activity), publish an agreement_version, and set a platform_setting key. Each maps to a small form, not free text — no natural-language capture surface exists yet for any of these. |
B1 |
| D2. Routing rules | Applies | Each operator input writes to exactly one table plus, where relevant, an audit trail: contract create/edit → contract (+ operator_audit_log row, action_type='contract_edit'); promo issue/deactivate → promo_code (+ operator_audit_log, action_type='promo_edit'); entitlement override → tenant_entitlement (source_type='override') (+ operator_audit_log, action_type='entitlement_override'); credit/note/trial-extend/tier-change → tenant_internal_activity (+ operator_audit_log for the privileged variants) with activity_type discriminating the sub-kind; tenant status change (suspend/reactivate/cancel) → tenant.status + a tenant_lifecycle_event row + operator_audit_log; agreement publish → agreement_version (+ operator_audit_log, action_type='agreement_publish'). No input fans out to more than 2 tables. |
B1c |
| D3. Maintenance | Applies | Rot points: (1) tenant_contact — emails go stale, is_primary/churn_risk drift out of date, marketing-consent fields never refreshed; (2) promo_code — expired-but-is_active=true codes accumulate (no scheduled deactivation job evidenced); (3) tier_definition/agreement_version — low-churn reference tables that can silently drift from what's actually sold/published; (4) tenant_entitlement — rows with ends_at in the past but status still 'active' (a stale-entitlement class distinct from the D5 gap below). A hygiene agent's safe-auto-repair set: flip tenant_entitlement.status to 'expired' once ends_at < now() (deterministic, reversible, no judgment call — could run at L5); flip promo_code.is_active=false once valid_until < now(). Draft-only: flagging a tenant_contact as likely-stale (bounced email, no NPS response in N months) for operator review — never auto-edit contact identity fields. |
B3 |
| D4. Error-prevention | Applies | Risky actions to block/warn before commit: (a) suspending/cancelling a tenant with status='active' and an unresolved tenant_data_lifecycle legal hold (legal_hold_flag=true) — must block, reads tenant_data_lifecycle.legal_hold_flag; (b) granting a tenant_entitlement override that duplicates an existing active row for the same (tenant_id, entitlement_code) without expiring the old one — should warn, reads tenant_entitlement unique-ish (tenant_id, entitlement_code) index; (c) deactivating a promo_code that has redemption_count > 0 and is referenced by an active subscription.promo_code_id — warn, reads subscription.promo_code_id; (d) publishing a new agreement_version with requires_acceptance=true while the type has no clear supersession chain set (supersedes_agreement_version_id null when a prior active version exists) — warn, reads agreement_version self-referencing FK; (e) transitioning tenant.status along an illegal lifecycle edge (e.g. trial → deleted directly) — must block, reads the state machine in §8 (already enforced by PlatformService, per module spec §6). |
B4 |
| D5. Negative-space | Applies | Concrete gap patterns: (1) a tenant with status IN ('active','past_due') and no row in billing_account — a tenant that should have Vrida-billing wired up but doesn't (query: tenant LEFT JOIN billing_account WHERE billing_account.id IS NULL AND tenant.status <> 'trial'); (2) a tenant with status='active' and no row in subscription at all, or none with status outside ('cancelled','ended') — active tenant with no live subscription; (3) a tenant_entitlement row with source_type='contract' but no matching contract row for that tenant (dangling source reference — source_id is a soft text ref, not an FK, so this can silently drift); (4) a contract with status='signed' and no corresponding subscription.source_contract_id pointing to it — a signed deal that never got provisioned; (5) a tenant past activated_at with zero tenant_setup_task rows marked task_category='onboarding' completed — onboarding silently stalled. |
B5 |
| D6. Decision-support | Applies (narrow) | Genuinely useful: dunning-recovery likelihood (given subscription.dunning_stage, dunning_started_at, and the tenant's own payment history of past recoveries, forecast probability this dunning cycle recovers vs. proceeds to suspended — history needed: payment.status history + subscription.dunning_* timeline) — this is explicitly named as AI-driven in §10. Also worth surfacing: anomalous spikes in ai_credit_transaction consumption rate vs. a tenant's trailing baseline (possible runaway agent loop or billing dispute precursor — history needed: ai_credit_transaction time series per tenant). Ruled out: full churn-prediction modeling — plausible but not yet scoped as a platform capability in the module spec; would need broader engagement signals platform alone doesn't hold (usage depth lives in other modules). |
B6 |
| D7. Autonomy boundary | Applies | See full per-action table below. | A4, A13 |
| D8. Evidence sources | Applies (narrow) | Real-world evidence that can create/modify records here: Stripe (or future Vrida-billing-vendor) webhook events drive subscription.status, payment rows, and dunning-state transitions (§11 "Vrida-billing-fed") — high-risk fields: payment.amount_cents, payment.status, subscription.status/dunning_status (a forged or replayed webhook could fraudulently mark a payment succeeded or clear dunning). These must match trusted master data: the webhook's stripe_customer_id/stripe_subscription_id must resolve to the exact billing_account.stripe_customer_id / subscription.stripe_subscription_id already on file for that tenant — never trust a webhook-supplied tenant_id directly. HelloSign/DocuSign envelope completion is the evidence source for agreement_acceptance.signature_ref — high-risk field: accepted_at/ip_address (forged completion could fabricate legal acceptance); must match the agreement_version_id and accepted_by_user_id already associated with the outstanding envelope. |
B1d, A11 |
| D9. Reconciliation pairs | Applies | (1) subscription_invoice.amount_paid_cents (rolled up from its payment rows) should reconcile with the sum of payment.amount_cents WHERE status='succeeded' for that invoice — break criteria: mismatch signals a missed webhook or a double-charge. (2) ai_credit_account.balance_cents should reconcile with granted_credit_cents + purchased_credit_cents - lifetime_spent_cents, and independently with the running balance_after_cents on the tenant's latest ai_credit_transaction row — break criteria: any drift indicates a lost/duplicated ledger write. (3) tenant.status should reconcile with subscription.status + dunning_status per the state-machine mapping in §8/§10 (e.g. tenant.status='suspended' should always pair with subscription.dunning_status='suspended' or a manual-suspension tenant_lifecycle_event) — break criteria: a tenant active while its only subscription is cancelled/ended, or vice versa. (4) tenant_usage_summary.sites_count should reconcile with the tenant's actual live site count in multi_loc.site (cross-module) — break criteria: usage rollup drifting from the metered-billing source of truth used for per-location overage (tier_definition.price_per_additional_location_cents). |
B13 |
| D10. Failure / rollback behavior | Applies | Every AI-assisted write in platform is currently at most L3 (draft) or a deterministic L5 auto-repair (per D7 below) — no L4+ money-moving autonomy exists yet, which simplifies rollback: (a) an agent-drafted tenant_entitlement override (automation_source='agent') — reversed by setting status='revoked' (soft, not a hard delete — deleted_at pattern also available); reversal window: unbounded, since the override's own starts_at/ends_at window is the natural undo boundary and a human can revoke anytime before or during that window. (b) an agent-flagged promo_code/tenant_entitlement auto-expiry (D3's safe-auto-repair) — reversed by an operator manually resetting status/is_active back; reversal window: unbounded (no side effects fire immediately from the flip itself). (c) An agent-drafted contract (hypothetical future case, per the review seam added to contract) — never auto-published; sits in review_status='pending' until a human approves, so there is nothing to roll back until a human has already signed off — the compensating action is simply review_status='rejected'. No platform table currently has a case where an AI-initiated write triggers an irreversible external side effect (e.g. an actual Stripe charge) — those are gated behind PlatformService's human-triggered billing calls, per §6/§10, not an autonomous path. |
A12, B11, C8 |
| D11. Adversarial / abuse surface | Applies | Attack surfaces: webhook payloads (Stripe or future vendor) claiming payment success/dunning recovery — fraud pattern: forged/replayed webhook marking a payment succeeded or a subscription out of dunning without money actually moving; deterministic validation: verify webhook signature at the ingestion boundary (outside this module's schema, but the schema's provider_payment_id/stripe_subscription_id correlation fields exist precisely to allow idempotent, matched reconciliation against the true Stripe object). Promo-code abuse — fraud pattern: scripted redemption attempts exceeding max_redemptions/redemption_count; deterministic validation: the count check already lives in PlatformService logic against promo_code.redemption_count/max_redemptions (schema supports it; enforcement is app-level). Operator-console abuse — fraud pattern: a compromised or over-scoped operator session mass-granting entitlement overrides or issuing promo codes; deterministic validation: operator_audit_log is immutable and captures before/after state + IP/UA for every privileged action, giving a forensic trail even though it doesn't prevent the action itself. Memory-poisoning (per the runbook's Section 0/2.2.1 bridge note): platform has no agent/tenant "operating memory" table today (that's B12, not built for this module) — so there is currently no write path into a memory subsystem that could be poisoned. If/when a future agent-memory store is added that platform-domain agents read from (e.g. "this tenant's typical entitlement request pattern"), any write into it must get the same trusted-source discipline A11 requires for documents/tools. Ruled out for now: no such surface exists yet in this module's actual schema. |
A11, A13 |
| D12. Offline behavior | Ruled out | Platform is a server-side control-plane module with no offline/frontline capture surface (unlike a POS or field-inventory module) — there is no "works offline, queues until reconnect" scenario here; every platform write happens against a live, connected admin console or a server-to-server webhook. Not forced. | A2, A3, A10 |
| D13. Channel sync | Applies (narrow) | The one real external-channel dependency is the Vrida-billing vendor (Stripe today, undecided long-term per §10/OPEN_ITEMS) — subscription, payment, billing_account must stay in sync with that vendor's own state. Conflict-resolution rule: the vendor is the source of truth for payment/subscription status; platform's rows are a mirror updated by webhook, never the reverse (platform never tells Stripe "actually the payment succeeded" — it only reflects). Fallback when the vendor is unreachable: subscription.status/dunning_status simply don't advance until the next successful webhook or a reconciliation job's poll; no destructive local assumption is made. This is not a multi-location or e-commerce channel sync (that's multi_loc/other modules) — platform's only "channel" is the billing vendor. |
B13, A2 |
| D14. Lifecycle / perishability | Applies | Multiple aging/expiring record classes: tenant.status itself is a full lifecycle machine (§8); subscription.trial_end_at (lapsing trial — signal: now() > trial_end_at while status='trial', indexed via subscription_trial_end_at sweep index → triggers trial-expiry conversion/cancellation); tenant_entitlement.ends_at (expiring entitlement — signal: now() > ends_at while status='active', action: flip to expired, this is D3's hygiene case); promo_code.valid_until(expiring promo — same pattern);contract.end_date(expiring Enterprise contract — signal: approaching/passedend_datewithstatus='active'andauto_renew=false→ surfaces to the Work Queue per §7);agreement_versionsupersession (an old version becomes stale once a newer one withrequires_acceptance=trueis published — signal:is_activeflips, prompting re-acceptance flows);tenant_data_lifecycle.deletion_scheduled_at/expires_at(retention-window aging toward actual data deletion);subscription.grace_period_ends_at` (dunning grace lapsing into suspension). All six are already schema-supported by existing NOT NULL/nullable timestamp columns and CHECK-constrained status enums — no new lifecycle-state columns are needed. |
B5, B6, B13 |
| D15. Capture modality & frontline ergonomics | Ruled out | Platform has no frontline/consumer capture modality (no scan, photo/vision, or voice input) — it is an operator-console/back-office/server-event module (§11: signup-fed, billing-fed, job-fed, or genuine manual form entry by ops/sales). The nearest thing to "capture" is an operator filling in a contract or promo-code form on admin.vrida.app — plain form input is both the primary and only modality; there is no secondary modality and no offline fallback question to answer (see D12). Not forced — this module genuinely has no frontline ergonomics surface. | B1, B1d, A2, A11 |
Capabilities Recorded
Applies:
- B1 (Capture & route engine) — narrow form-based capture only (D1/D2); no NL command-bar surface exists or is planned for platform's operator forms.
- B3 (Self-maintaining master data) —
tenant_contactstaleness, expired-but-still-activepromo_code/tenant_entitlementrows (D3). - B4 (Guided action / error prevention) — legal-hold-aware suspension blocking, duplicate-entitlement warnings, promo-deactivation-in-use warnings, agreement-supersession warnings, illegal lifecycle-transition blocking (D4).
- B5 (Negative-space detection) — active tenant missing billing_account/subscription, dangling contract-sourced entitlements, signed-but-unprovisioned contracts, stalled onboarding (D5).
- B6 (Decision support) — dunning-recovery likelihood, AI-credit-consumption anomaly detection (D6, narrow).
- B11 (Consequence preview) / A12 (Rollback) — every current platform AI-assisted write is reversible and capped at L3/L5; no irreversible autonomous action exists today (D10).
- B13 (Continuous reconciliation) — invoice/payment sum reconciliation, AI-credit-ledger reconciliation, tenant-status/subscription-status reconciliation, usage-summary/multi_loc site-count reconciliation (D9); billing-vendor channel sync (D13).
- A4 (authority ladder) + A13 (SoD) — full per-action table below (D7).
- A11 (adversarial defense) — webhook forgery/replay, promo-redemption abuse, operator-console abuse; no memory-poisoning surface exists yet (D11).
Ruled out:
- B2 (one-sentence ambient analyst) — not walked explicitly by any D-question in this module's answers; platform's admin console is exception-first via the Work Queue (§7) rather than an ambient one-liner surface, and no module-spec capability names this. Plausible future fit, not evidenced today.
- B7 (tenant-config defaults), B8 (outcome planner, globally deferred), B9 (autonomous exception & approval queue — partially present via the Work Queue concept in §7 but not schema-backed as a distinct queue table), B12 (tenant operating memory), B14 (business simulation), B15 (AI onboarding/bulk import — onboarding here is tenant setup task tracking, not bulk data import), B16 (outbound drafting), B17/B18 (conversational query/assistant), B19 (AI reporting), B20 (personalized marketing) — none triggered by any of the 15 D-questions against platform's actual tables; these are either global infrastructure (B12) not yet needed by platform specifically, or capabilities that belong to modules with end-customer-facing surfaces platform doesn't have.
- D12 (offline behavior) — no offline/frontline surface in this server-side control-plane module.
- D15 (capture modality/frontline ergonomics) — no frontline capture surface; plain admin-console forms only.
D7 — Full Per-Action Autonomy Boundary Table
| Action | Authority level | Boundary classification |
|---|---|---|
Auto-expire a tenant_entitlement once ends_at < now() (status → expired) |
L5 (execute-within-limits) — deterministic date check, fully reversible | may-act-alone |
Auto-deactivate a promo_code once valid_until < now() (is_active → false) |
L5 — deterministic date check, fully reversible | may-act-alone |
Flag a stale tenant_contact (bounced email, no NPS response) for operator review |
L2 (suggest) | draft-only |
Draft a tenant_entitlement override (new beta/promo grant) for operator confirmation |
L3 (draft) | draft-only |
Draft a promo_code (new discount code) for operator confirmation |
L3 | draft-only |
Draft Enterprise contract terms (hypothetical future — review seam exists) |
L3, gated by contract.review_status seam |
needs-approval |
| Compute dunning-recovery-likelihood forecast and surface it (no write) | L1/L2 (explain/suggest) | may-act-alone (read-only, no state change) |
| Detect AI-credit-consumption anomaly and alert (no write) | L1/L2 | may-act-alone (read-only, no state change) |
| Adaptive dunning retry-timing / recovery-messaging (§10, explicitly AI-driven) | L3–L4 depending on tenant risk tier — drafts retry schedule, human/ops can override; escalates suspension-triggering step | draft-only (retry timing) / needs-approval (the actual suspend transition) |
Grant/adjust ai_credit_account.spend_limit_cents or balance_cents |
none currently automated — always operator-entered | needs-approval |
Record a payment as succeeded/refunded |
driven only by verified webhook events (deterministic ingestion, not agentic judgment) — not an "AI decision" at all | may-act-alone (deterministic system, not agent judgment) but never an agent's discretionary call |
Approve a payment / release funds / change billing_account.stripe_default_payment_method_id |
financial action under A13 | never (AI may never be the sole approver of money movement or payment-method changes; always human-executed via the billing vendor's own auth flow) |
Transition tenant.status along the lifecycle machine (suspend/cancel/reactivate) |
high-consequence, cross-cutting (access cutoff, billing impact) | needs-approval (may be system-triggered deterministically for dunning-driven active→past_due/past_due→suspended, but a manual/abuse-driven suspension always needs a human operator) |
Publish a new agreement_version (legal document) |
legal/compliance-critical | never for AI alone — always human legal/ops action; AI may draft summary text only, not publish |
Approve/reject a tenant_data_lifecycle deletion request past pending_approval |
destructive, irreversible once executed | needs-approval (human operator must move pending_approval → scheduled) |
Halt a scheduled deletion (pending_deletion → cancelled safety valve) |
irreversible-adjacent, favors human safety valve | needs-approval |
| Grant an entitlement override that bypasses tier limits at no charge | revenue-impacting | needs-approval |
Impersonate a tenant user (operator_audit_log action_type='impersonation_start') |
highest-sensitivity access action | never for AI-initiated; always a human operator action, logged |
Real Gaps Found
No schema gaps found for the 6 emphasized questions (D3, D5, D7, D9, D10, D11) or D14 — the 2026-07-06 autonomy backfill's automation_source, review_status/review_reason/reviewed_by_actor_id/reviewed_at, agent-as-actor FKs, and metadata-as-decision-provenance already cover every capability this walk surfaced as needing schema support (entitlement grants, promo issuance, contract review, audit attribution).
One narrow, deferrable observation (not a hard gap — flagged for completeness, not urgent):
- D6/D9 decision-support and reconciliation both want a lightweight system-detected "anomaly" or "reconciliation break" record (e.g. "this tenant's AI-credit burn rate looks anomalous" or "this invoice's amount_paid doesn't match its payment rows") that currently has no home table — today these would have to live as an ad hoc
tenant_internal_activityrow (activity_typedoesn't have an'anomaly_detected'or'reconciliation_break'value in its CHECK list) or anoperator_audit_logentry that doesn't quite fit either (operator_audit_logis about privileged operator actions, not system-detected findings). Missing field: no CHECK-list value exists for a system/agent-authored finding distinct from an operator-performed action. Urgency: deferrable — this is a cross-cutting pattern (every module's B6/B13 will eventually want "where do detected findings live"), not unique to platform, and platform has zero rows of this kind today (no B6/B13 service layer exists yet to populate one). Best resolved once a second module's Part D walk confirms the same need, so the shape is designed once, not per-module — consistent with how the 2026-07-06 canonical pattern itself was designed once and applied four times.
Remediation Plan Phase 1 (2026-07-08)
CHECK/RLS/REVOKE/trigger-only, no table/column count change (23 tables / 408 cols unchanged). Two fixes in this module, closing gaps a senior-architect review found: (1) operator_audit_log's original no-RLS design assumption (service_role/superuser only) was invalidated by this phase's own blanket per-schema authenticated GRANT — fixed by REVOKEing authenticated's SELECT/INSERT/UPDATE/DELETE on this one table, not by adding a tenant-scoped RLS policy (wrong fit for a cross-tenant operator log). (2) platform.reject_append_only_mutation() — a new shared trigger function (RAISE EXCEPTION, SQLSTATE P0001) — added here as belt-and-suspenders enforcement for Item 4's 9 append-only ledger tables across ai/tax/identity/billing/inventory/pos, on top of the REVOKE UPDATE/DELETE already applied at the grant level. Full cross-module record: PROJECT_DECISIONS #37.
Remediation Plan Phase 2 (2026-07-08)
PK-generation-strategy change only, no table/column count change (23 tables / 408 cols unchanged). id DEFAULT changed from gen_random_uuid() to platform.uuid_generate_v7() on 6 append-only event/audit/ledger tables: agreement_acceptance, ai_credit_transaction, operator_audit_log, tenant_internal_activity, tenant_lifecycle_event, tenant_usage_summary. Why: UUIDv7 is time-ordered, keeping future time-range partitioning possible on these append-only ledgers without a PK rewrite — impossible once data lands on a random UUIDv4 PK. platform.uuid_generate_v7() itself is also new — defined in platform (packages/db/migrations/20260708180000_phase2_uuidv7_function.sql) as a shared, cross-cutting function referenced by column defaults across 9 other schemas, matching the precedent of platform.reject_append_only_mutation() from Remediation Phase 1 above. Full cross-module record: PROJECT_DECISIONS #38.
Remediation Plan Phase 4 (2026-07-08)
+3 tables / +32 cols — 23 → 26 tables, 408 → 440 cols. All additive; zero DROP TABLE/DROP COLUMN anywhere in this phase. Full cross-module record: PROJECT_DECISIONS #40.
accounting_period(Item 14, 9 cols) — fiscal-period tracker:id,tenant_id,period_start,period_end,status,closed_at,closed_by_actor_id,created_at,updated_at. This codebase's first use of PostgresEXCLUDE USING gist(btree_gistextension newly enabled for it):excl_accounting_period_no_overlapprevents two overlapping periods for the same tenant while different tenants' identical date ranges remain unaffected. A shared trigger function,platform.flag_closed_period_business_date(), is FLAG-NOT-REJECT (setsreview_status='pending', never raises) — POS is offline-first, so a genuine June-30 sale can sync in July after June's period has closed; rejecting it would silently lose the sale. Consumed bypos.sale/pos.sale_refund(BEFORE INSERT OR UPDATE OF business_date) andpos.register_cash_entry(BEFORE INSERTonly, since Phase 1's own append-only trigger on that table already blocks every UPDATE unconditionally).legal_entity(Item 15, 8 cols) —id,tenant_id,name,ein_ref,is_primary,is_active,created_at,updated_at. 1:N from tenant, so a tenant can incorporate a 2nd LLC without splitting into two tenants.legal_entity_tenant_id_primary_unique(partial uniqueWHERE is_primary=true) allows exactly one primary entity per tenant. Backfilled 1 row per existing tenant (1997 rows at build time). A nullableentity_idwas added to 10 header tables codebase-wide:platform.contract,platform.billing_account,admin.compliance_document,tax.tax_calculation,billing.ar_account,billing.vendor_payable,purchasing.vendor_invoice,purchasing.purchase_order,orders.order_header,pos.sale— deliberately header-only (never line items, neverpurchasing.vendor).platform.contractandplatform.billing_accounteach gainedentity_idas their own +1 col.outbox(Item 20a, 13 cols) —id,tenant_id,aggregate_type,aggregate_id,event_type,payload,status,attempts,last_attempted_at,delivered_at,error,created_at,updated_at. A durable transactional-outbox event table, genuinely MUTABLE (not append-only) —gen_random_uuid()PK, notplatform.uuid_generate_v7(), since Phase 2's UUIDv7-for-append-only-ledgers rule doesn't apply to a table whosestatus/attempts/delivered_atare expected to change post-insert. No consumer/dispatcher service exists yet.
Column-count reconciliation: accounting_period (9) + legal_entity (8) + outbox (13) = 30, plus contract.entity_id (+1) and billing_account.entity_id (+1) = 32. 408 + 32 = 440.
Header/Line Remediation Fix #1 (2026-07-10)
+1 table / +10 cols — 26 → 27 tables, 440 → 450 cols. This is the 3rd and final reopen (of 3: POS → Purchasing → Platform) in a new, coordinated "Header/Line Remediation" effort (design doc ~/Downloads/vrida-header-line-remediation-design-2026-07-10.md, §1), a systemic sweep for missing header/line reconciliation, unenforced immutability claims, and bare (non-composite) cross-tenant FKs across the codebase. POS landed fix #8 first (PROJECT_DECISIONS #46); Purchasing landed fixes #10/#12/#4 next (PROJECT_DECISIONS #47). Full record: PROJECT_DECISIONS #48.
subscription_invoice_line(10 cols) —id,tenant_id,subscription_invoice_id,line_type,description,quantity,unit_amount_cents,amount_cents,currency_code,created_at. Per-line decomposition of asubscription_invoice's total (base subscription, AI-credit overage, additional seats/sites, proration, one-time addons, discounts, tax). Write-once (Decision B case 1, matchingtax.tax_calculation_jurisdiction's precedent) — nostatus/updated_at/deleted_at. PK defaultsplatform.uuid_generate_v7(), notgen_random_uuid()(Remediation Phase 2's append-only-ledger convention). Composite FKsubscription_invoice_line_invoice_tenant_fkey→subscription_invoice(id, tenant_id)— required a new prerequisiteUNIQUE(id, tenant_id)onsubscription_invoiceitself, confirmed missing before this migration.- Reconciliation pattern: LINES ARE TRUTH (the opposite of Purchasing's own
vendor_credit_line, which is header-is-truth).trg_subscription_invoice_line_sync_totals(functionplatform.sync_subscription_invoice_totals_from_lines()),AFTER INSERT OR UPDATE OR DELETEon the line table, unconditionally recomputessubscription_invoice.subtotal_cents/discount_cents/tax_cents/total_centsfromSUM(lines.amount_cents)grouped byline_type(discount_centsstored as a positive magnitude even though the discount line's ownamount_centsis negative). The header'samount_due_cents/amount_paid_centspayment bookkeeping is untouched. - A genuinely reusable lesson: migration-sequencing when a reconciliation trigger and a backfill-plus-verify step touch the same table. Design verification (§7 finding V3-1) caught the single most serious bug of the whole 3-module effort: the original draft's order was create table + install the sync trigger + backfill + verify. Since the trigger is
AFTER INSERT/UPDATE/DELETEand unconditionally overwrites the header fromSUM(lines), it would fire during the backfill — meaning the "verify against the existing storedsubtotal_cents" step would compare against totals the trigger had already overwritten, a tautological check that always "reconciles" even where the original data genuinely diverged. Corrected 8-step order, worth reusing whenever a future module needs both a backfill and a reconciliation trigger on the same pair of tables: (1) inspect the real source data's shape first (here,line_itemsJSONB — confirmed zero code path anywhere in the repo ever populated it); (2) add any prerequisite constraint (here,UNIQUE(id, tenant_id)); (3) create the new table EMPTY, no trigger yet; (4) snapshot the parent's pre-backfill totals into a temp table; (5) backfill, guarding against malformed source values (jsonb_typeof(...) = 'array', sincejsonb_array_elements()throws on a non-array); (6) verify the backfill against the snapshot, not the live/post-trigger columns — log mismatches viaRAISE NOTICE, don't silently fix them; (7) only now install the sync trigger; (8) deprecate the superseded source column in place (COMMENT ON COLUMN, not dropped). - Disclosed data-quality finding, not a migration bug. All 5 pre-existing live
subscription_invoicerows heldline_items='[]'(the column default) despite each having a non-zerosubtotal_cents(9900/4900/9900/5000/5000) — confirmed via pre-migration inspection that zero code path in this repo ever actually wrote toline_items. The backfill correctly produced zero new lines for all 5, and the verification step correctly logged all 5 as reconciliation mismatches against the snapshot — a genuine, pre-existing gap between the header and a blob nothing ever populated, disclosed rather than silently papered over.line_itemsis deprecated in place (comment-only), not dropped.
Header/Line Remediation Batch 2 — Closing Bare-FK Fix (2026-07-10)
Constraint-only, no table/column count change (27 tables / 450 cols unchanged). platform.payment.invoice_id — disclosed as a bare FK during fix #1's own independent verification above — is now upgraded to composite payment_invoice_id_tenant_fkey (invoice_id, tenant_id) → subscription_invoice(id, tenant_id), using the UNIQUE(id, tenant_id) prerequisite fix #1 already added. This is the LAST item in the entire 2-batch Header/Line Remediation effort. Full detail: docs/database/schema_docs/platform.md's own "Header/Line Remediation Batch 2" subsection. See PROJECT_DECISIONS #53.
- Verification. Live-reproduced and test-confirmed:
apps/api/src/platform/__tests__/platform-billing.spec.tsGroup J (J1–J6) covers INSERT-triggers-sync, tax+discount recomputation with the sign convention, DELETE-triggers-resync, the amount/quantity CHECK, the composite-FK cross-tenant rejection, and theline_typeCHECK. A separate agent's independent verification pass confirmed all of the above true and live, with one unrelated, already-disclosed concern noted (platform.payment.invoice_id's own pre-existing bare FK, out of this fix's scope).