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 references primary_site_id and 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 references platform.tenant/tenant_profile for 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 payments module, 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 Postgres EXCLUDE USING gist (the btree_gist extension 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 (sets review_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 by pos.sale/pos.sale_refund (BEFORE INSERT OR UPDATE OF business_date) and pos.register_cash_entry (BEFORE INSERT only, 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 unique WHERE is_primary=true) allows exactly one primary entity per tenant. Backfilled 1 row per existing tenant (1997 rows at build time). A nullable entity_id was 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 on purchasing.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, not platform.uuid_generate_v7(), since Phase 2's UUIDv7-for-append-only-ledgers rule doesn't apply to a table whose status/attempts/delivered_at are 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.
  • EntitlementsresolveEntitlements(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 auditrecordOperatorAction(...), 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).

  1. Tenants — per-tenant hub (overview, profile, contacts, lifecycle, onboarding, usage, data & retention, internal notes).
  2. Subscriptions & Billing — billing account, subscription, invoices, payments, AI credits, dunning.
  3. Plans & Entitlements — global tier catalog + per-tenant entitlement overrides.
  4. Contracts & Promotions — tenant contracts + global promo codes (main manual-entry surface).
  5. 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 override replaces 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 billinguse_credits draws down the prepaid balance (Vrida-granted + purchased) first; spend_limit_cents caps overage (null = uncapped); every AI call writes a consumption row to ai_credit_transaction for 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_contact staleness, expired-but-still-active promo_code/tenant_entitlement rows (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_activefalse) 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_approvalscheduled)
Halt a scheduled deletion (pending_deletioncancelled 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_activity row (activity_type doesn't have an 'anomaly_detected' or 'reconciliation_break' value in its CHECK list) or an operator_audit_log entry that doesn't quite fit either (operator_audit_log is 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 Postgres EXCLUDE USING gist (btree_gist extension newly enabled for it): excl_accounting_period_no_overlap prevents 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 (sets review_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 by pos.sale/pos.sale_refund (BEFORE INSERT OR UPDATE OF business_date) and pos.register_cash_entry (BEFORE INSERT only, 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 unique WHERE is_primary=true) allows exactly one primary entity per tenant. Backfilled 1 row per existing tenant (1997 rows at build time). A nullable entity_id was 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, never purchasing.vendor). platform.contract and platform.billing_account each gained entity_id as 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, not platform.uuid_generate_v7(), since Phase 2's UUIDv7-for-append-only-ledgers rule doesn't apply to a table whose status/attempts/delivered_at are 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 a subscription_invoice's total (base subscription, AI-credit overage, additional seats/sites, proration, one-time addons, discounts, tax). Write-once (Decision B case 1, matching tax.tax_calculation_jurisdiction's precedent) — no status/updated_at/deleted_at. PK defaults platform.uuid_generate_v7(), not gen_random_uuid() (Remediation Phase 2's append-only-ledger convention). Composite FK subscription_invoice_line_invoice_tenant_fkeysubscription_invoice(id, tenant_id) — required a new prerequisite UNIQUE(id, tenant_id) on subscription_invoice itself, 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 (function platform.sync_subscription_invoice_totals_from_lines()), AFTER INSERT OR UPDATE OR DELETE on the line table, unconditionally recomputes subscription_invoice.subtotal_cents/discount_cents/tax_cents/total_cents from SUM(lines.amount_cents) grouped by line_type (discount_cents stored as a positive magnitude even though the discount line's own amount_cents is negative). The header's amount_due_cents/amount_paid_cents payment 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/DELETE and unconditionally overwrites the header from SUM(lines), it would fire during the backfill — meaning the "verify against the existing stored subtotal_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_items JSONB — 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', since jsonb_array_elements() throws on a non-array); (6) verify the backfill against the snapshot, not the live/post-trigger columns — log mismatches via RAISE 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_invoice rows held line_items='[]' (the column default) despite each having a non-zero subtotal_cents (9900/4900/9900/5000/5000) — confirmed via pre-migration inspection that zero code path in this repo ever actually wrote to line_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_items is 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.ts Group 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 the line_type CHECK. 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).
Last modified: Jul 10, 2026, 2:35 PM PT
On this page
Esc