Cross-Module Contracts
Rules for how modules talk to each other. Covers service-class isolation, event patterns, and the actual seam catalog.
Seam catalog sourced from each module's design lock. Updated at every schema lock (Section 6 lock gate).
Core Principle
Modules are isolated. They communicate through service-class APIs and events, NOT direct database queries.
Rules
Rule 1: Service classes are the public API
Each module exports a service class. Examples:
InventoryService— public API for inventory modulePOSService— public API for POS moduleOrdersService— public API for orders module
Other modules import and call these services. They do not query tables.
Rule 2: Tables are private to their module
- Tables in
inventory.*schema are owned byInventoryService - Only
InventoryServicewrites to inventory tables - Cross-module reads MAY be allowed via controlled views (documented per module)
Rule 3: Events for one-to-many notifications
When a module's state changes meaningfully, it emits an event. Other modules subscribe.
Events use the NestJS event emitter or a message queue. Example event names (non-exhaustive):
inventory.stock_changedpos.sale_completedorders.reservation_createdorders.reservation_fulfilledcrm.customer_createdplatform.subscription_changed(SaaS subscription events belong to Platform, not Billing — Billing is merchant A/R + A/P)
Rule 4: Synchronous calls for query/command operations
When POS needs "is this variant in stock?", it calls InventoryService.getAvailableQty(variant_id).
When POS completes a sale, it calls InventoryService.completeSale(idempotency_key, ...).
Rule 5: Asynchronous events for side effects
After InventoryService.completeSale() executes, an event is emitted. Notifications, Audit, and other modules subscribe to update their own state.
Rule 6: Materialized View Invalidation
Reporting materialized views joining across schemas declare their source-schema dependencies. Source modules emit *_changed events; Reporting subscribes and refreshes affected MVs. Refresh strategy per MV:
- event-driven (fast-changing data)
- scheduled (slow-changing)
- read-through staleness window (low-priority dashboards)
Each MV must declare its strategy in the Reporting module spec.
Rule 7: Audit Event Isolation
Modules emitting audit-eligible events do NOT subscribe to audit.* events themselves. Audit's internal events (audit.log_entry_created, audit.integrity_check_completed, etc.) are consumed only by compliance dashboards. No operational module subscribes to audit.*. This prevents event cycles.
Standing Patterns
These patterns appear repeatedly across the seam catalog. Rationale for each lives in docs/decisions/PROJECT_DECISIONS.md and the relevant module spec.
| Pattern | Rule |
|---|---|
| reference-don't-copy | Modules point at each other's records via FK or UUID reference; they do NOT copy data. Audit points at source rows via source_table/source_ref; Files owns metadata and owning modules hold file_id. |
| owner-processes-own-webhooks | The module that owns an external-service account processes that service's webhooks. Payments owns the Stripe event log and processes Stripe webhooks. Integrations owns connector_webhook_event and processes connector webhooks. |
| config-in-owner / runtime-in-consumer | Config lives in the module that owns the concept (integration_config → Admin). Runtime state lives where execution happens (sync runs, webhook delivery → Integrations). |
| usage-here / limit-in-platform | Usage counters live in the module that generates them (tenant_storage_usage → Files, renamed from file_storage_usage at the 2026-07-11 build; notification_quota_usage → Notifications). The platform-level tier limit lives in platform.tenant_entitlement. Never add tier limits to module schemas. |
| consent-is-CRM's | CRM owns all consent and opt-out records. Notifications queries crm.customer_consent at send-time and writes opt-outs back via CRMService. Notifications owns ZERO consent tables — this is legally load-bearing. |
| forward-ref FKs | When a module references a table in a not-yet-locked module, it stores a plain UUID or text field. The FK constraint is added at the later module's lock. All forward-refs are labelled in docs/SCHEMA.md. |
Disallowed Patterns
- ❌ POS code directly executing
SELECT * FROM inventory.variant - ❌ Orders code directly executing
UPDATE inventory.variant SET qty = ... - ❌ Any module querying
crm.customer_consentdirectly without going throughCRMService - ❌ Any module calling the Stripe API directly (must go through
PaymentsService) - ❌ Any module calling Bedrock / R2 / external search directly (must go through the service layer)
- ❌ Modules sharing types/interfaces beyond what's exposed in their service contract
- ❌ Notifications owning consent or opt-out tables (those are CRM's)
Allowed Patterns
- ✅
POSServicecallingInventoryService.completeSale() - ✅
PricingServicecalled at checkout to resolve price →sale_line.unit_price_cents - ✅
NotificationsServicequeryingCRMService.getConsent()at send-time - ✅
BillingServicewriting back topurchasing.vendor_invoiceviaPurchasingService(documented seam) - ✅ Materialized views in
reporting.*joining across schemas (read-only, owned by Reporting) - ✅ Service classes returning DTOs defined in the source module
Cross-Module Seam Catalog
Actual seams between locked modules, sourced from each module's design lock. Every new lock must add its seams here (Section 6, item 11).
POS
pos is module #14, schema-locked 2026-07-07 (10 tables, 160 columns as of Remediation Phase 4 — up from 143, see PROJECT_DECISIONS #40) — see PROJECT_DECISIONS #27. Offline-first: sale/sale_payment/sale_refund carry client_uuid/origin/sync_status for device-generated dedup. Remediation Phase 3 (2026-07-08) restored sale.sale_number (register-session-prefixed, +1 col), relaxed sale_refund_line.sale_line_id to nullable + added item_variant_id for the no-receipt-refund path (+1 col; sale_refund.sale_id itself stays NOT NULL — anonymous walk-in return is a separate, undecided question, flagged not bundled), added refund-tax-capture columns (sale_refund_line.tax_amount_cents/.tax_rate, sale_refund.tax_refunded_amount_cents, +3 cols), and widened the fail-closed tender gate to also block 'reward' (renamed chk_sale_payment_no_unvalidated_stored_value_tender → chk_sale_payment_no_unbacked_tender_type). Remediation Phase 4 (2026-07-08, PROJECT_DECISIONS #40) DECIDED the previously-flagged anonymous-walk-in-return question = ALLOW: sale_refund.sale_id relaxed to nullable, chk_sale_refund_identification requires a linked sale OR a documented reason, never neither (audit controls — reason quality, attribution, approval — deferred to service layer, see OPEN_ITEMS). Also added tender_type_catalog (+1 table, additive-interim alongside the unchanged tender-type enum), the fiscal-period flag-not-reject trigger (consuming platform.accounting_period, see the Platform section below), and sale.entity_id. Reopened 2026-07-11 (companion reopen to the new returns module build, PROJECT_DECISIONS #61): sale_refund/sale_refund_line each gained UNIQUE(id, tenant_id) — the prerequisite for returns' own composite FKs into both tables (constraint-shape only, table/column counts unchanged). Schema-only so far — POSService doesn't exist yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| POS | Multi-Location | register.site_id, sale.site_id → multi_loc.site.id (enforced FKs, NOT NULL, ON DELETE RESTRICT, confirmed live at pos's 2026-07-07 lock) |
Every register and every sale is site-scoped for multi-site tenants. |
| POS | Inventory | sale_line.item_variant_id → inventory.item_variant.id (enforced FK, ON DELETE RESTRICT, confirmed live) — line-item identity. InventoryService.completeSale(idempotency_key) (service-layer, not yet built) |
Line-item identity FK plus stock decrement; creates inventory.stock_movement. idempotency_key (deterministically derived from client_uuid) prevents double-decrement on offline sync. |
| POS | Inventory | inventory.stock.available_qty (on_hand_qty - reserved_qty, generated column, read-only) |
No-oversell netting — already nets out Orders' reservations; no pos-side reservation table needed. inventory.stock_reservation.source_type CHECK does NOT include 'pos' (confirmed live) — reservations stay Orders' own mechanism. |
| POS / Orders | Pricing | Hard Contract 1 — now SATISFIED by BOTH pos and orders (confirmed live at pos's 2026-07-07 lock and again at orders' 2026-07-07 lock). pos.sale_line / orders.order_line both carry all 6 required snapshot fields, verbatim field-for-field: resolved_amount_minor_units (bigint), charged_amount_minor_units (bigint), currency_code (char(3)), tax_treatment (text), resolving_price_rule_id (nullable FK → pricing.price_rule, ON DELETE RESTRICT), resolved_quantity (numeric). Live-tested on both (pos test C1, orders test E1): superseding the resolving price_rule to a new price does not change either table's already-recorded snapshot amounts. |
Resolved price snapshotted at sale/order-confirmation time — the historical record of what the customer actually paid, never re-derived from the live rule. |
| CRM | POS | sale.customer_id → crm.customer.id (nullable FK, ON DELETE RESTRICT, confirmed live) |
Anonymous sales OK. CRM customer linked when present. |
| Shared | POS | sale.currency_code / sale_line.currency_code / sale_payment.currency_code → shared.currency.iso_code (enforced FKs, ON DELETE RESTRICT, confirmed live) |
Global ISO 4217 currency validation, same precedent as multi_loc/crm/inventory/pricing. |
| Identity | POS | Every *_by_actor_id column across all 9 pos tables → identity.actor.id (enforced FKs, confirmed live) |
Actor attribution for the full autonomy-first pattern (human or AI actor on every mutation), reusing the canonical pattern established by the 2026-07-06 autonomy-first backfill (PROJECT_DECISIONS #19). |
| POS | Payments | sale_payment.stripe_payment_intent_id / charge_account_ref — plain nullable text, NO FK (forward-ref, deferred; payments schema does not exist in v2 yet). Mirrors Billing's own ar_payment/ap_payment precedent. |
POS stores the Stripe Terminal payment-intent reference and the charge-account reference now; the FK is added when Payments locks. POS never calls Stripe directly — PaymentsService will own that. |
| POS | Billing | sale_payment.charge_account_ref text seam → billing.ar_charge |
Charge-account tender triggers A/R entry in Billing (same forward-ref column as the Payments row above; billing also not yet built). |
| Orders | POS | orders.order_header.fulfilled_sale_id → pos.sale (enforced FK, ON DELETE RESTRICT, confirmed live at orders' 2026-07-07 lock; pos.sale needs no reciprocal column, by design) |
Link-don't-convert: order links to the POS sale that fulfilled it. Tax finalizes at POS. |
| Platform | POS | sale.entity_id → platform.legal_entity.id (nullable FK, Remediation Phase 4 Item 15, PROJECT_DECISIONS #40) |
Which legal entity within the tenant a sale belongs to, when a tenant operates multiple LLCs. |
| Platform | POS | platform.flag_closed_period_business_date() trigger (Remediation Phase 4 Item 14, PROJECT_DECISIONS #40) fires on sale/sale_refund (BEFORE INSERT OR UPDATE OF business_date) and register_cash_entry (BEFORE INSERT only — its own pre-existing append-only trigger already blocks all UPDATEs) |
Flags (never blocks) a row whose business_date falls within a closed platform.accounting_period — deliberately non-blocking since offline-sync needs a late-arriving sale to still land. |
| Returns | POS | returns.return_authorization.source_sale_id → pos.sale(id, tenant_id) / .return_authorization_line.sale_line_id → pos.sale_line(id, tenant_id) (composite FKs, nullable, read-only); returns.return_resolution.pos_sale_refund_id → pos.sale_refund(id, tenant_id) (composite, required on resolution_type='refund') / .replacement_sale_id → pos.sale(id, tenant_id) (composite). New 2026-07-11 (PROJECT_DECISIONS #61) — see the dedicated Returns section below for the full seam. |
An RMA reads the originating sale/sale-line and references (never duplicates) the POS-side refund/replacement sale that executes the money movement. |
Orders
orders is module #15, schema-locked 2026-07-07 (7 tables, 174 columns as of Remediation Phase 4 — up from 173, see PROJECT_DECISIONS #40) — see PROJECT_DECISIONS #29, completing the sell path (crm → pricing → inventory → orders → pos). The first module built under the Design-Phase Integrity rules — zero v1 tables consolidated or dropped. Reopened 2026-07-11 (companion reopen to the new returns module build, PROJECT_DECISIONS #61): order_header/order_line each gained UNIQUE(id, tenant_id) — the prerequisite for returns' own composite FKs into both tables (constraint-shape only, table/column counts unchanged). Schema-only so far — OrderService doesn't exist yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Orders | Multi-Location | order_header.site_id, order_fulfillment.site_id → multi_loc.site.id (enforced FKs, NOT NULL, confirmed live) |
Every order and every fulfillment batch is site-scoped for multi-site tenants. |
| Orders | CRM | order_header.customer_id/order_template.customer_id → crm.customer.id (nullable FKs, confirmed live — anonymous quotes fully supported); order_fulfillment.ship_to_address_id → crm.address.id (nullable FK, confirmed live, required when fulfillment_type='ship') |
Customer linkage and ship-to address for delivery/shipping fulfillment. |
| Orders | Inventory | order_line.stock_reservation_id → inventory.stock_reservation.id (enforced FK, nullable, confirmed live — the reservation seam) via InventoryService.reserve() (service-layer, not yet built) at order confirmation, never at quote |
Creates inventory.stock_reservation (already anticipating source_type='order' before this module existed); order_line.stock_reservation_id holds the reference. Reserve-vs-move: this only soft-holds via reserved_qty, never moves physical stock. |
| Orders | POS | order_header.fulfilled_sale_id → pos.sale (enforced FK, ON DELETE RESTRICT, confirmed live — deleting a referenced sale is rejected, live-tested); order_payment.pos_sale_payment_id → pos.sale_payment (enforced FK, confirmed live) |
Link-don't-convert: order links to the POS sale/payment that fulfilled it. Tax finalizes at POS. See the POS section above for this seam from POS's own side. |
| Orders | Pricing | order_line.resolving_price_rule_id → pricing.price_rule.id (enforced FK, nullable, ON DELETE RESTRICT, confirmed live) |
Hard Contract 1 traceability pointer — see the Pricing section below for the full contract text, now SATISFIED by both pos and orders. |
| Orders | Shared | order_header/order_line/order_payment .currency_code → shared.currency.iso_code (enforced FKs, confirmed live, no hardcoded default) |
Global ISO 4217 currency validation. |
| Identity | Orders | Every *_by_actor_id/*_actor_id column across all 7 orders tables → identity.actor.id (enforced FKs, confirmed live) |
Actor attribution for the full autonomy-first pattern, reusing the canonical pattern (PROJECT_DECISIONS #19). |
| Orders | Purchasing | order_header.draft_po_id → purchasing.purchase_order — CLOSED 2026-07-07 at purchasing's lock (constraint order_header_draft_po_id_fkey, added by purchasing's migration via a single ALTER on the existing column — not an orders reopen). Live-tested (a bogus draft_po_id is rejected with 23503). |
Special orders that trigger a PO. The forward-ref orders locked without is now a real enforced FK. |
| Orders | Payments | order_payment.stripe_payment_intent_id — plain nullable text, NO FK (forward-ref, deferred; payments schema does not exist in v2 yet) |
FK added when Payments locks. |
| Platform | Orders | order_header.entity_id → platform.legal_entity.id (nullable FK, Remediation Phase 4 Item 15, PROJECT_DECISIONS #40) |
Which legal entity within the tenant an order belongs to, when a tenant operates multiple LLCs. |
| Returns | Orders | returns.return_authorization.source_order_id → orders.order_header(id, tenant_id) / .return_authorization_line.order_line_id → orders.order_line(id, tenant_id) (composite FKs, nullable, read-only — same pattern as the POS seam above, for orders never fulfilled through POS). New 2026-07-11 (PROJECT_DECISIONS #61) — see the dedicated Returns section below for the full seam. |
An RMA reads the originating order/order-line it traces back to. |
Purchasing
purchasing is module #16, schema-locked 2026-07-07 (16 tables, 402 columns as of Remediation Phase 4 — up from 398, see PROJECT_DECISIONS #40) — see PROJECT_DECISIONS #30, completing the supply loop opposite orders/pos (the BUY path). All 16 v1 tables built, zero consolidated. Reopened a 3rd time 2026-07-10 for the Receiving extraction (the 2nd instance in this codebase of moving tables out of an already-locked module into a brand-new one — the 1st was approvals out of admin, PROJECT_DECISIONS #44): purchase_receipt/purchase_receipt_line MOVED out entirely into the new receiving schema/module (renamed goods_receipt/goods_receipt_line), folding in 2 fixes named at Purchasing's own prior Header/Line Remediation reopen (PROJECT_DECISIONS #47) as blocked on exactly this event — fix #7 (movement-line linkage to inventory.stock_movement_line) and fix #11 (tenant-configurable over-receipt tolerance). Purchasing also gained 4 new UNIQUE(id, tenant_id) prerequisites (vendor, vendor_address, purchase_order, purchase_order_line) and retargeted both bare FKs pointing at the moved table (vendor_invoice_match's and vendor_return_line's own purchase_receipt_line_id columns, both RENAMED goods_receipt_line_id and upgraded to composite). Purchasing is now 15 tables / 353 columns (down from 17/410 immediately pre-move — a count this section's own intro above never separately restated after the 2026-07-10 Header/Line Remediation batch, PROJECT_DECISIONS #47/#53, added vendor_credit_line; disclosed here rather than silently carried forward). See the new Receiving section below for the extracted module's own full seam catalog — PROJECT_DECISIONS #55. Schema-only so far — PurchasingService doesn't exist yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Purchasing | Inventory | RETIRED, 2026-07-10 — the tables this row described no longer exist in Purchasing. purchase_receipt/purchase_receipt_line were extracted into the new receiving module (renamed goods_receipt/goods_receipt_line, PROJECT_DECISIONS #55); the receiving↔inventory movement seam (inventory_movement_id, now stock_movement_id plus the new stock_movement_line_id) is no longer a Purchasing dependency at all. See the new Receiving section below (its own Receiving-to-Inventory row) for the current, equivalent seam. |
Historical pointer only — see the Receiving section below for the live seam. |
| Purchasing | Inventory | The return seam (REAL FK): vendor_return_line.inventory_movement_id → inventory.stock_movement.id (movement_type='returned'). |
Goods shipped back to a vendor become an outbound movement. |
| Purchasing | Inventory | The reorder signal (read-only): a reorder agent reads inventory.stock.reorder_point/reorder_qty/available_qty to draft a purchase_order. Signal lives on inventory.stock, NOT stock_adjustment_request. |
Low-stock detection → an agent-drafted PO (automation_source='agent', review_status='pending'; PO-send stays human-gated). |
| Purchasing | Inventory | purchase_order_line.variant_id / vendor_item.variant_id → inventory.item_variant.id (enforced FKs); vendor_return_line.lot_id → inventory.lot.id. purchase_receipt_line.variant_id/.lot_id MOVED to receiving.goods_receipt_line, 2026-07-10 — see the new Receiving section below. |
The purchased/vendor-item identity + return-line lot tracking still inside Purchasing; received-line identity/lot tracking is now Receiving's own seam. |
| Purchasing | Orders | purchase_order.source_order_id → orders.order_header.id (REAL FK, confirmed live). |
A special order triggers a PO. Reciprocal of the draft_po_id closure (Orders section above). |
| Purchasing | CRM | vendor.linked_customer_id → crm.customer.id (nullable, REAL FK). |
The grower-who-also-buys-retail overlap — link, don't merge (vendor stays a separate purchasing-owned entity). |
| Purchasing | Shared | vendor/PO/invoice/return/credit .currency_code → shared.currency.iso_code; vendor_address.country_code → shared.country.iso_alpha2, .region_code → shared.administrative_region.iso_3166_2; purchase_uom_id → shared.unit_of_measure.code. Receipt's own currency_code MOVED with goods_receipt/goods_receipt_line to Receiving, 2026-07-10 — see the new Receiving section below. |
Global currency/jurisdiction/UOM validation. |
| Purchasing | Multi-Location | purchase_order.site_id / vendor_invoice.site_id / vendor_return.site_id / vendor_credit.site_id → multi_loc.site.id (NOT NULL). goods_receipt.site_id MOVED to Receiving, 2026-07-10 (still → multi_loc.site.id, now composite) — see the new Receiving section below. |
Every PO/invoice/return/credit is site-scoped; every goods receipt is now site-scoped under Receiving's own seam. |
| Identity | Purchasing | every *_by_actor_id across all 16 tables → identity.actor.id. |
Actor attribution (autonomy-first pattern). No new authority mechanism — pure consumer of agent_duty_grant. |
| Billing | Purchasing | Write-back (target columns live, write unmade — BillingService doesn't exist yet): vendor_invoice.billing_ap_ref/payment_status_ref/paid_at and purchase_order.amount_paid_cents — plain columns, NO FK; Billing writes them (amount_paid_cents as a SUM-rollup across every vendor_payable tracing to that PO, never a 1:1 copy). vendor_invoice.status deliberately has no 'paid' — Billing owns payment, Purchasing owns the invoice document + 3-way match. billing.vendor_payable.vendor_invoice_id → purchasing.vendor_invoice.id is the built-side link (see Billing section below). |
A/P payment state flows back into the invoice; PurchasingService writes no value to these columns. |
| Search | Purchasing | search_vector on vendor (deferred — Search doesn't exist, pg_trgm not enabled; column NOT built). |
Vendor search, when Search is built. |
| Files | Receiving | MOVED, 2026-07-10. shipment_photo_ref moved with purchase_receipt → receiving.goods_receipt (still a deferred forward-ref, plain text, no FK — files schema still doesn't exist). No longer a Purchasing dependency — see the dedicated Files section below (its own Receiving row) for the current pointer. |
Receiving-dock photo, when Files is built — tracked under Receiving now, not Purchasing. |
| Platform | Purchasing | purchase_order.entity_id/vendor_invoice.entity_id → platform.legal_entity.id (nullable FKs, Remediation Phase 4 Item 15, PROJECT_DECISIONS #40) |
Which legal entity within the tenant a PO/invoice belongs to, when a tenant operates multiple LLCs. |
| Shared | Purchasing | vendor.payment_terms_id/purchase_order.payment_terms_id → shared.payment_terms_catalog.id (nullable FKs, Remediation Phase 4 Item 17b, PROJECT_DECISIONS #40, additive-interim alongside any existing free-text terms field) |
The catalog now genuinely represents complex terms like "2/10 net 30" that a bare enum couldn't. |
The receiving/goods-receipt tables have MOVED (GUARD/note): Purchasing's former purchase_receipt/purchase_receipt_line tables are no longer part of Purchasing. As of 2026-07-10 (PROJECT_DECISIONS #55) they moved out into their own dedicated module, receiving (renamed goods_receipt/goods_receipt_line) — see the dedicated Receiving section directly below for the full seam catalog. This is the 2nd instance in this codebase of moving tables out of an already-locked module into a brand-new one (1st: approvals out of admin, PROJECT_DECISIONS #44). Purchasing retains zero receiving-lifecycle tables as of this reopen; the 3-way match (vendor_invoice/vendor_invoice_match) and vendor returns (vendor_return/vendor_return_line) stay in Purchasing, now pointing OUT at receiving.goods_receipt_line instead of an internal table (see the Receiving section's own Purchasing (3-way match) / Purchasing (vendor returns) rows).
Receiving
receiving is a NEW module (schema-locked 2026-07-10, PROJECT_DECISIONS #55) — split OUT of Purchasing into its own dedicated schema, the 2nd instance in this codebase of moving tables out of an already-locked module into a brand-new one (1st: approvals out of admin, PROJECT_DECISIONS #44; see the Admin/Approvals sections above for that precedent). This amends Purchasing's own lock (PROJECT_DECISIONS #30/#47) — see the Purchasing section above for the reopen-delta accounting. 2 tables / 62 columns: goods_receipt (33 cols — MOVED + RENAMED from purchasing.purchase_receipt, was 30; +3 NEW: voided_at/voided_by_actor_id/void_reason, closing a real gap where status already had a 'void' value with zero recorded attribution) and goods_receipt_line (29 cols — MOVED + RENAMED from purchasing.purchase_receipt_line, was 27; +2 NEW: stock_movement_line_id — fix #7, the real line-grain movement linkage — and reversal_of_goods_receipt_line_id, a self-referencing composite FK for the correction/reversal path). Folds in 2 header/line-remediation fixes explicitly deferred to this extraction at Purchasing's own prior reopen (PROJECT_DECISIONS #47): fix #7 (movement-line linkage to Inventory) and fix #11 (tenant-configurable over-receipt tolerance, flag-or-block). 13 composite (child_col, tenant_id) → parent(id, tenant_id) FKs are new or upgraded-from-bare in this build — every one individually live-tested for cross-tenant rejection. Schema-only so far — ReceivingService doesn't exist yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Receiving | Purchasing | goods_receipt.purchase_order_id → purchasing.purchase_order.id (composite goods_receipt_purchase_order_tenant_fkey) and goods_receipt_line.purchase_order_line_id → purchasing.purchase_order_line.id (composite goods_receipt_line_purchase_order_line_tenant_fkey) — line-grain, the #1 ERP correctness point (partial receipts reconcile per PO line, not per PO header), both confirmed live. ReceivingService.receive() (service-layer, not yet built) writes back purchase_order_line.received_qty using ONLY the capped absorbed_qty = LEAST(accepted_qty, ordered_qty − received_qty − invoiced_qty − cancelled_qty) — NEVER the raw accepted_qty — which is what keeps Purchasing's own chk_purchase_order_line_quantity_rollup (fix #10, unmodified) structurally unviolable regardless of tolerance settings. Live-tested: an over-tolerance receipt (accepted_qty=120 vs ordered_qty=100, zero prior received_qty) writes back exactly 100, never 120 (over_short_qty=20); a further, uncapped +20 write-back attempt on top of that is independently confirmed to violate the rollup CHECK, proving the cap is load-bearing. |
PO-line-grain receiving reconciliation and write-back. goods_receipt_line.over_short_qty (= accepted_qty − absorbed_qty, >0 only when over-tolerance) is fix #11's own trigger input — see the Admin row below. |
| Receiving | Purchasing | goods_receipt.vendor_id → purchasing.vendor.id (composite goods_receipt_vendor_tenant_fkey) and .ship_from_vendor_address_id → purchasing.vendor_address.id (composite goods_receipt_ship_from_vendor_address_tenant_fkey, nullable) — both confirmed live, both upgraded from bare the moment this table left purchasing's own schema. |
Which vendor shipped the goods, and which of that vendor's addresses they shipped from. |
| Receiving | Inventory | goods_receipt_line.variant_id → inventory.item_variant.id (composite, NOT NULL) / .lot_id → inventory.lot.id (composite, nullable) identify what was received; .stock_movement_id → inventory.stock_movement.id (composite, nullable, RENAMED from inventory_movement_id — the header-grain link, kept per this codebase's "deprecate in place" convention) and the NEW .stock_movement_line_id → inventory.stock_movement_line.id (composite, nullable — fix #7, the real line-grain linkage) record the resulting movement. ReceivingService.receive() (service-layer, not yet built — mirrors Purchasing's own pre-existing InventoryService.receive(idempotency_key) seam verbatim) writes an inventory.stock_movement (movement_type='received', source_module='purchasing' — a disclosed, deliberate naming asymmetry, NOT renamed to 'receiving') and updates item_variant.avg_cost_cents (weighted-average). Receiving NEVER writes inventory.stock directly — the same v1 guard Purchasing's own receiving seam always carried, preserved verbatim through this move. All 4 FKs confirmed live, individually cross-tenant-tested. |
Received goods become an inventory movement (header + line grain) + a weighted-average cost update. |
| Receiving | Multi-Location | goods_receipt.site_id → multi_loc.site.id (composite goods_receipt_site_tenant_fkey, NOT NULL, confirmed live — upgraded from bare the moment this table left purchasing). |
Every goods receipt is site-scoped for multi-site tenants. |
| Purchasing (3-way match) | Receiving | purchasing.vendor_invoice_match.goods_receipt_line_id → receiving.goods_receipt_line.id (composite vendor_invoice_match_goods_receipt_line_tenant_fkey, RENAMED from purchase_receipt_line_id, confirmed live) — one of 2 dependencies on the moved table, disclosed at Purchasing's own prior reopen as blocked on exactly this event (PROJECT_DECISIONS #47/#53). |
3-way match (PO ↔ goods receipt line ↔ vendor invoice line) still resolves across the new schema boundary — live-tested end to end. |
| Purchasing (vendor returns) | Receiving | purchasing.vendor_return_line.goods_receipt_line_id → receiving.goods_receipt_line.id (composite vendor_return_line_goods_receipt_line_tenant_fkey, RENAMED from purchase_receipt_line_id, confirmed live) — the 2nd dependency on the moved table, missed in this extraction's own first design draft and only caught by independent verification. |
A vendor return still traces back to the specific goods-receipt line it's returning, across the new schema boundary. vendor_return_line's OWN separate inventory_movement_id → inventory.stock_movement.id (the outbound return movement) is untouched by this build — see the Purchasing section above. |
| Identity | Receiving | Every *_actor_id column across both tables (goods_receipt.received_by_actor_id/.verified_by_actor_id/.created_by_actor_id/.reviewed_by_actor_id/.voided_by_actor_id, goods_receipt_line.created_by_actor_id) → identity.actor.id (enforced FKs, confirmed live). |
Actor attribution for the full autonomy-first pattern, carried over unchanged from Purchasing's own pre-move columns (PROJECT_DECISIONS #19). |
| Shared | Receiving | goods_receipt.currency_code / goods_receipt_line.currency_code → shared.currency.iso_code (enforced FKs, confirmed live). |
Global ISO 4217 currency validation, carried over unchanged from Purchasing's own pre-move columns. |
| Receiving | Admin (read-only, no FK) | trg_goods_receipt_line_check_over_receipt_tolerance (fires BEFORE INSERT OR UPDATE OF accepted_qty (corrected post-independent-verification from an initial OF over_short_qty scoping) — a deliberate, disclosed narrowing from the design's own literal "BEFORE INSERT OR UPDATE" wording, avoiding re-flipping a human review decision on an unrelated later edit) reads admin.tenant_setting/admin.setting_definition (category='receiving', keys over_receipt_tolerance_percent/over_receipt_tolerance_action) — a trigger-level read, NO FK (same non-FK treatment every other tenant_setting consumer in this codebase gets). Precedence: site-scoped tenant_setting row > tenant-wide tenant_setting row > setting_definition catalog default — all 3 levels live-tested, including a site-scoped 'block' override correctly outranking a tenant-wide 'flag' override for the same tenant. First real cross-module consumer of admin.setting_definition/tenant_setting documented anywhere in this catalog. |
A tenant's (optionally site-scoped) over-receipt tolerance threshold and action ('flag' default, or 'block') — when a line's over_short_qty exceeds it, the line is either rejected outright (block) or the parent goods_receipt.review_status flips to 'pending' (flag, reusing the existing FULL-autonomy review seam, zero new columns needed). |
The reversal design (self-referencing FK + compensating movement, live-tested): a correction is a NEW goods_receipt_line row with reversal_of_goods_receipt_line_id (composite self-FK goods_receipt_line_reversal_tenant_fkey) pointing back at the line it compensates, paired with a NEW inventory.stock_movement_line carrying a NEGATIVE quantity_delta (same stock_movement.correlation_id as the original) — the original append-only stock_movement/stock_movement_line rows are NEVER edited or deleted (live-tested: a direct mutation attempt on the original is itself DB-rejected). The reversal's sign lives entirely in the linked stock_movement_line.quantity_delta and in the reversal_of_goods_receipt_line_id link itself — goods_receipt_line's own received_qty/accepted_qty columns always record a magnitude (e.g. 10, never -10); chk_goods_receipt_line_qty_nonneg has no reversal carve-out and would reject a negative value there. This precise clarification was a genuine build-time finding, not stated explicitly in the original design doc.
receiving boundary note (GUARD) — what receiving explicitly does NOT own:
- The PO lifecycle — belongs to Purchasing, unchanged. Receiving only records the receiving event against an existing
purchase_order/purchase_order_line. inventory.stockmutation — belongs to Inventory exclusively. Receiving only ever writesinventory.stock_movement/stock_movement_linerows (confirmed live) — the same v1 guard Purchasing's own receiving seam always carried, preserved verbatim through this move.- 3-way match / vendor invoicing — belongs to Purchasing's
vendor_invoice/vendor_invoice_match, unchanged. Receiving is referenced BY that match (vendor_invoice_match.goods_receipt_line_id), not the other way around. - The over-receipt tolerance VALUE — belongs to Admin (
tenant_setting/setting_definition). Receiving only reads it; it owns no tolerance-configuration table of its own.
Tax
tax is module #17, schema-locked 2026-07-07 (3 tables, 47 columns as of Remediation Phase 4 — up from 39, see PROJECT_DECISIONS #40) — see PROJECT_DECISIONS #31, the first module in the entire build with zero v1 precedent (v1 fully outsourced tax to Stripe Tax with no local schema). Never computes a rate — persists what an external provider (Stripe Tax) returned, per jurisdiction, as a durable legal record. Schema-only so far — TaxService doesn't exist yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| POS / Orders | Tax | The source seam (polymorphic, no reciprocal column): tax_calculation.source_ref → pos.sale_line.id, orders.order_line.id, or (Remediation Phase 3, Item 9) pos.sale_refund_line.id, validated by a widened source_module/source_type CHECK pair (same pattern as billing.ar_charge.source_ref). |
Every tax calculation traces back to the exact sale line, order line, OR refund line that triggered it. None of the three carries a reciprocal column. |
| POS (refund) | Tax | The reversal seam (Remediation Phase 3, Item 9): tax_calculation.reversed_calculation_id → tax.tax_calculation.id (self-FK, same-tenant-only validated by trg_tax_calculation_validate_reversal) + calculation_type IN ('original','reversal'). A reversal row carries NEGATIVE taxable_amount_cents/total_tax_amount_cents (sign-aware CHECKs), so SUM(original + reversal) nets to zero for remittance reporting with no per-row branching. pos.sale_refund_line.tax_amount_cents/.tax_rate and pos.sale_refund.tax_refunded_amount_cents capture the refund's own tax portion at the POS layer (plain positive magnitudes — the sign convention lives only in tax.tax_calculation). tax_calculation_jurisdiction's own per-jurisdiction sign is enforced by trg_tax_calculation_jurisdiction_validate_sign (added same-day post-verification; this table is INSERT-only, so a BEFORE INSERT trigger suffices — no CHECK could reference the parent row's calculation_type). |
Closes the over-reporting-remittance-every-refund gap: a refund's tax reversal is now representable and nets out automatically at report time. |
| CRM | Tax | tax_calculation.customer_id → crm.customer.id; tax_calculation.applied_exemption_certificate_id → crm.customer_tax_certificate.id (nullable). |
Exemption certificates are read, never duplicated locally. |
| Tax | Billing (forward) | billing.ar_charge.tax_calculation_id → tax.tax_calculation.id (nullable, REAL FK — Tax built first). |
Billing never duplicates the jurisdiction breakdown; single source of truth lives in Tax. |
| Tax | Payments (deferred) | tax_calculation.provider_ref — plain text, no FK; Payments doesn't exist in v2. |
The Stripe Tax API call reference, once Payments is built. |
| Identity | Tax | created_by_actor_id/reviewed_by_actor_id → identity.actor.id. |
Actor attribution (autonomy-first pattern). No new authority mechanism — pure consumer of agent_duty_grant; the only agent surface is tax:calculation:flag_anomaly (draft_only/observational, never overrides a calculation). |
| Shared / Multi-Location | Tax | currency_code → shared.currency.iso_code; site_id → multi_loc.site.id. |
Standard global/site scoping. |
| Tax (internal) | Tax | tax.jurisdiction_level_catalog (Remediation Phase 4 Item 17c, PROJECT_DECISIONS #40, 6 seeded rows incl. 'country') + tax_calculation_jurisdiction.jurisdiction_level_id (nullable FK, additive-interim alongside the unchanged jurisdiction_level CHECK-enum, itself separately widened to also accept 'country') |
Closes the VAT/GST gap — 'country'-level tax jurisdictions are now representable, both in the catalog and in the pre-existing enum. |
| Platform | Tax | tax_calculation.entity_id → platform.legal_entity.id (nullable FK, Remediation Phase 4 Item 15, PROJECT_DECISIONS #40) |
Which legal entity within the tenant a tax calculation belongs to, when a tenant operates multiple LLCs. |
The orders↔pos reconciliation gap (disclosed, not a schema bug): an orders-time tax estimate (is_estimate=true) and its later pos-time final calculation (is_estimate=false) deliberately COEXIST as independent tax_calculation rows — supersedes_calculation_id is scoped to same-source_ref corrections only and is never set across this pair, because no line-level link exists between order_line and sale_line (only the header-level orders.order_header.fulfilled_sale_id → pos.sale). Reporting reconciles the two via that existing header-level join. See PROJECT_DECISIONS #31, module_spec/tax.md DR-A.
Billing
billing is module #18, schema-locked 2026-07-07 (9 tables, 164 columns as of Remediation Phase 4 — up from 162, see PROJECT_DECISIONS #40) — see PROJECT_DECISIONS #32, the SETTLE half of the financial layer (tax calculates, billing settles), built same day immediately after tax. All 8 v1 tables preserved 1:1, plus 1 NEW table (ar_adjustment, the write-off/dispute-resolution lifecycle v1 deferred). Schema-only so far — BillingService doesn't exist yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| POS / Orders | Billing | ar_charge.source_ref/.source_payment_ref (polymorphic, plain uuid, NO single-table FK — a two-target polymorphic column structurally cannot carry one) → pos.sale/orders.order_header and pos.sale_payment/orders.order_payment, validated by the source_module/source_type pair CHECK. No reciprocal column on either locked table. |
An event-driven charge-account tender becomes an A/R entry in Billing, traceable back to the exact sale/order (and payment installment, if any) that triggered it. |
| Tax | Billing | billing.ar_charge.tax_calculation_id → tax.tax_calculation.id (nullable, REAL FK). |
Billing references the jurisdiction-level tax breakdown for a charge without duplicating it — see Tax section above. |
| Billing | Purchasing | BillingService writes vendor_invoice.billing_ap_ref, payment_status_ref, paid_at via PurchasingService; also maintains purchase_order.amount_paid_cents as a SUM-rollup across every vendor_payable tracing to that PO (not a naive 1:1 copy — one PO can have multiple vendor_invoices/payables). |
A/P control: when a vendor payable is paid, Billing writes back to the vendor invoice AND recomputes the PO-level paid total. PurchasingService writes no value here. billing.vendor_payable.vendor_invoice_id → purchasing.vendor_invoice.id (REAL FK, UNIQUE — one payable per invoice) is the built-side link. |
| Billing | Payments | ar_payment.stripe_payment_intent_id, ap_payment.stripe_payment_intent_id (text seam → payments.payment_intent) |
Payment intents referenced for settlement. FK added at Payments lock (forward-ref). |
| CRM | Billing | Remediation Phase 3, Item 8 (2026-07-08, PROJECT_DECISIONS #39): billing.ar_account reads crm.customer.credit_limit_cents/.credit_terms — service-layer read, no FK (tenant identity/customer facts are never looked up cross-schema by FK in this codebase, matching Platform/Admin's own precedent). crm.customer OWNS the columns (credit-worthiness is a relationship judgment about the customer); ar_account owns the balance and reads the limit, never duplicates it. NULL credit_limit_cents means no credit extended (fail-closed). |
Corrects a live crm↔billing orphan — billing.ar_account's own comment already said this; crm.customer's said the columns were removed entirely, an incomplete v1→v2 migration. See the CRM section's boundary note below for the same fix from CRM's side. |
| Platform | Billing | ar_account.entity_id/vendor_payable.entity_id → platform.legal_entity.id (nullable FKs, Remediation Phase 4 Item 15, PROJECT_DECISIONS #40) |
Which legal entity within the tenant an A/R account or A/P payable belongs to, when a tenant operates multiple LLCs. |
| Shared | Billing | shared.exchange_rate (Remediation Phase 4 Item 16, PROJECT_DECISIONS #40) — no direct FK consumer yet; instead a new billing.validate_ar_payment_application_currency() trigger (trg_ar_payment_application_validate_currency, BEFORE INSERT only on ar_payment_application, itself append-only) enforces that ar_payment.currency_code, ar_charge.currency_code, and (when the same account backs both) ar_account.currency_code all agree |
A EUR payment can no longer be applied to a USD charge (or vice versa), and a payment/charge pair that agree with each other but not with their shared account is also rejected. |
The ar_charge idempotency fix (live-tested): a live NULL-distinctness bug was caught and fixed during this build's own test-writing pass — source_payment_ref being nullable meant a naive single 5-column unique index would let two charges for the same source_ref both land with a NULL source_payment_ref without colliding, silently defeating retry-dedup for the common event-driven-charge case. Fixed via a two-partial-unique split (ar_charge_idempotency_full_unique + ar_charge_idempotency_no_payment_ref_unique), mirroring orders.order_header's own idempotency_key precedent exactly.
Payments
payments is module #19, schema-locked 2026-07-07 (9 tables, 158 columns) — see PROJECT_DECISIONS #33, the money-MOVEMENT layer (billing records, payments executes), built the module directly after billing. All 8 v1 tables preserved, plus 1 NEW table (terminal_reader, closing the pos.register hardware-pairing gap logged in OPEN_ITEMS row 185). Schema-only so far — PaymentsService doesn't exist yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Payments | POS / Orders / Billing | payment_intent.source_ref (polymorphic, plain uuid, NO FK — cannot enforce across 3 possible target tables) → pos.sale_payment.id / orders.order_payment.id / billing.ar_payment.id, validated by chk_payment_intent_source_pair. PaymentsService (not yet built) writes payment outcome back to the source row's .status (pos.sale_payment.status / orders.order_payment.status / billing.ar_payment.status) after a Stripe webhook — service-layer only, not FK-enforced. |
Terminal/online/A/R payment execution result (captured / failed / refunded) flows back to whichever module's row triggered the charge. |
| Payments | POS | terminal_reader.register_id → pos.register.id (nullable, REAL FK). |
A physical Stripe Terminal reader is associated with a register (or left unassigned/mobile). |
| Payments | (all) | Stripe webhooks processed by PaymentsService only — owner-processes-own-webhooks |
No other module processes Stripe events. |
| Payments | Platform | NOT wired — a confirmed, permanent scope boundary (v1's own incoming-only decision, re-confirmed at this build). billing.ap_payment.stripe_payment_intent_id stays permanently unwired; Stripe Connect is for receiving customer payments, not paying vendors. platform.subscription.status's column comment was corrected at this build to distinguish Vrida's own SaaS-billing Stripe integration (direct, tenant pays Vrida) from this module's PaymentsService (Connect, tenant's customers pay the tenant) — see OPEN_ITEMS' reconciled platform-naming row. |
Nothing — this row documents a boundary, not a data flow. |
Admin
Admin's v2 build is a FIRST PASS (not a reopen) — v1 was 11 tables/145 cols, locked 2026-06-10, never rebuilt for v2 until this pass, 2026-07-07. v2 Admin: 10 tables / 128 columns — tenant_business_profile (17 cols) DROPPED entirely; 16 of its 17 columns MOVED to platform.tenant_profile (Platform's 4th reopen), 1 (attributes) dropped outright with no successor (zero known consumers, no defined shape). Full 17-column fate mapping lives in PROJECT_DECISIONS #34 Block 3 (cited, not re-derived here). The 10 surviving tables are a FAITHFUL PORT of v1 — same columns, types, CHECKs, indexes — with exactly one schema-level change: 5 columns across 3 tables (tenant_setting, api_key ×2, approval_request ×2 — approval_request and its 2 sibling tables have since MOVED to the new approvals module, PROJECT_DECISIONS #44, and approval_request's own retargeted requested_by_actor_id was further RENAMED initiator_actor_id at that same move; see the Approvals section below) retargeted from identity.identity_user to identity.actor, matching the actor-attribution convention every module built since 2026-06-28 uses. See PROJECT_DECISIONS #34 (governing boundary rule) and #36 (this build). Reopened 2026-07-08, Remediation Phase 3 Item 13(PROJECT_DECISIONS #39): +1 table, admin.setting_definition (12 cols — the config-key catalog covering admin.tenant_setting; global reference data, no tenant_id, no RLS, mirrors identity.permission's catalog-table precedent). NOT enforced against tenant_setting via FK or trigger — a disclosed, deferred gap (see OPEN_ITEMS). Covers tenant_setting only; integration_config.settings/hardware_device.config/identity.agent_identity.config/ai's own config fields are deliberately out of scope. Reopened again 2026-07-08, Remediation Phase 4 Items 15/17d/19 (PROJECT_DECISIONS #40): +2 tables — integration_provider_catalog (7 cols, global/no-RLS, 6 seeded rows) + integration_config.provider_id; custom_field_definition (12 cols, tenant-scoped WITH RLS, governs the 7 confirmed ungoverned attributes JSONB columns codebase-wide) — plus compliance_document.entity_id (nullable FK → platform.legal_entity). That reopen made it 13 tables / 161 columns. Reopened a 3rd time 2026-07-09 (PROJECT_DECISIONS #44): the tenant-side approval engine EXTRACTED entirely into its own new module. approval_workflow (-10 cols), approval_routing_rule (-11 cols), and approval_request (-18 cols) all MOVED out to the new approvals module (see the dedicated Approvals section below for their new shape and seams) — this REVERSES PROJECT_DECISIONS #34 Section 5's Option B decision ("stays Admin-internal"). -3 tables / -39 columns. Admin is now 10 tables / 122 columns. Schema-only so far — AdminService doesn't exist yet.
Identity boundary (reconciling with the Platform section below): Admin does NOT own tenant identity. platform.tenant_profile is the single source of truth for a tenant's legal name, business type, EIN, addresses, DBAs, and NAICS classification (PROJECT_DECISIONS #34/#35 — see the fuller governing-rule note in the Platform section below, not restated here). Admin references platform.tenant for identity via a service-layer read (PlatformService), never an FK, and never duplicates it — zero identity-shaped columns exist anywhere across Admin's 10 tables, confirmed at this build.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Admin | Platform | Service-layer read of PlatformService.getTenantProfile(tenantId) — no FK, by convention (tenant identity is never looked up cross-schema by FK in this codebase). Every table carries tenant_id → platform.tenant.id (enforced FK) for tenant scoping. |
Admin reads tenant legal name, business type, addresses, DBAs, NAICS, EIN-vault-ref from Platform at render/config time; stores none of it itself. See the Platform section below for this same seam from Platform's side. |
| Admin | Files (deferred) | tenant_branding.logo_ref, compliance_document.document_ref — plain forward-refs, no FK (files schema not yet built). Unchanged by this build — same seam already documented in the Files section above. |
Brand logo + compliance PDFs, once Files exists. |
| Admin | Vault (deferred) | integration_config.credentials_ref, webhook_config.secret_ref — plain forward-refs, no FK (no vault-encryption service exists anywhere in the codebase yet — confirmed via grep, zero Vault/Encryption service classes). Same net-new dependency platform.tenant_profile.ein_ref already depends on, not a second one. |
3rd-party connector credentials + webhook signing secrets, once a vault service exists. |
| Integrations (forward) | Admin | integrations.connector.integration_config_id → admin.integration_config; integrations.webhook_delivery.webhook_config_id → admin.webhook_config (both forward, integrations not yet built). Admin's schema needs no placeholder — these are real, already-documented FK targets, nothing incomplete on Admin's side. Unchanged by this build — same seams already in the Integrations section above. |
Connector runtime config anchor + outbound webhook delivery config anchor, once Integrations exists. |
| Identity | Admin | All 3 retargeted actor-attribution columns (tenant_setting.updated_by_actor_id, api_key.created_by_actor_id/.revoked_by_actor_id — the other 2 retargeted columns, approval_request.requested_by_actor_id/.resolved_by_actor_id, moved out with the table itself; see the Identity → Approvals row in the new Approvals section below) plus every other *_actor_id column across the 10 tables → identity.actor.id (enforced FKs, confirmed live). |
Actor attribution for the autonomy-first pattern (PROJECT_DECISIONS #19), consumed here for the first time by Admin. |
| Platform | Admin | compliance_document.entity_id → platform.legal_entity.id (nullable FK, Remediation Phase 4 Item 15, PROJECT_DECISIONS #40) |
Which legal entity within the tenant a compliance document belongs to, when a tenant operates multiple LLCs. |
| Admin (internal) | Admin | admin.integration_provider_catalog (Remediation Phase 4 Item 17d, PROJECT_DECISIONS #40, 6 seeded rows) + integration_config.provider_id (nullable FK, additive-interim alongside integration_config's existing free-form provider identification) |
A structured provider catalog now exists alongside the pre-existing unstructured shape. |
| (all product modules) | Admin | admin.custom_field_definition (Remediation Phase 4 Item 19, PROJECT_DECISIONS #40) governs the 7 confirmed ungoverned attributes JSONB columns codebase-wide: crm.customer, inventory.item, inventory.item_variant, orders.order_header, receiving.goods_receipt (RENAMED from purchasing.purchase_receipt in the entity_type CHECK vocabulary at the 2026-07-10 Receiving extraction, PROJECT_DECISIONS #55 — 11 live rows using the old value, all confirmed test-fixture debris, were backfilled to the new value in the same migration, not left dangling on a retired CHECK value), purchasing.vendor, purchasing.vendor_item — no FK from these tables to custom_field_definition (a JSONB column can't be FK-constrained); the registry is descriptive metadata, not schema-enforced against the JSONB shape it describes. |
A tenant-defined custom-field key registry any of these 7 tables' attributes column MAY use — not yet consumed by any service layer. |
The approval engine has MOVED — REVERSING PROJECT_DECISIONS #34 Section 5's Option B decision (GUARD/note): Admin's former tenant-side approval engine (approval_workflow → approval_routing_rule → approval_request) is no longer part of Admin. As of 2026-07-09 (PROJECT_DECISIONS #44) it moved out into its own dedicated module, approvals (8 tables, 97 cols) — see the dedicated Approvals section below for the full seam catalog, the still-forward-compatible design, and the still-NOT-converged relationship with platform.contract's own separate Vrida-operator review gate. Admin retains zero approval-engine tables as of this reopen.
Approvals
approvals is a NEW module (schema-locked 2026-07-09, PROJECT_DECISIONS #44) — the generic, cross-module approval-workflow ENGINE, split OUT of Admin into its own dedicated module (see the Admin section above for the extraction accounting: -3 tables/-39 cols, Admin now 10 tables/122 cols). 8 tables / 97 columns: approval_workflow (12 cols — MOVED from admin.approval_workflow, was 10; +2 NEW: step_mode sequential/parallel/conditional, and blocks_agent_approver — the C8 financial-autonomy boundary anchor, write-locked via REVOKE-and-re-GRANT so a tenant-scoped write path cannot flip it), approval_routing_rule (11 cols — MOVED unchanged from admin.approval_routing_rule), approval_policy (10 cols — NET-NEW self-approval/SoD config; min_distinct_approvers is now DB-enforced via a trigger, not just declared intent), approval_request (20 cols — MOVED from admin.approval_request, was 18; requested_by_actor_id RENAMED initiator_actor_id and made NOT NULL, closing a null-approver bypass bug found during design verification; source_module CHECK widened 4→8 values; step_history DROPPED, superseded by the new approval_step table), approval_step (14 cols — NET-NEW per-request step instances, with 3 triggers closing self-approval, parallel-step quorum-spoofing, and the C8 agent-approver boundary), approval_delivery (12 cols — NET-NEW approver-notification attempts), approval_token (10 cols — NET-NEW one-click approve/reject tokens, hash-only storage, one atomic single-use redemption UPDATE), approval_event (8 cols — NET-NEW append-only audit log, reuses platform.reject_append_only_mutation()). workflow_type's old CHECK-enum (po_approval/discount_approval/refund_approval/other) was REMOVED from both approval_workflow and approval_routing_rule — now free text, since it baked business-process names into the engine's own schema (a domain-knowledge leak found and fixed during design). Schema-only so far — no ApprovalsService yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Approvals | Purchasing / POS / Orders / Identity / CRM / Inventory / AI / Offers / Returns / Agents / (other) | approval_request.source_ref — polymorphic, plain uuid, NO FK (cannot enforce a single FK across the named target tables — the widest polymorphic seam in this codebase) → a row in the naming module's own domain tables, validated by chk_approval_request_source_module (CHECK on source_module only; widened 2026-07-11, PROJECT_DECISIONS #61, to accept 'returns'; widened again 2026-07-16, agents-v2/v3 build Phase 5, to also accept 'agents' — an agent task/action/decision requiring human sign-off can request approval routing the same way a PO, refund, or RMA can, 11 values total). source_type stays free text, deliberately NOT CHECK-enumerated, so an already-supported module can add a new record kind without forcing an approvals-schema migration. |
Any module's record (a PO, a discount override, a refund, a return authorization, an access request, a customer merge, a stock adjustment, an agent action, etc.) can request tenant-side approval routing without Approvals needing to know its shape. Same polymorphic-no-FK pattern already established by tax.tax_calculation.source_ref / billing.ar_charge.source_ref / payments.payment_intent.source_ref. See the new Agents section further down this file, and the new ai.agent_execution.approval_request_id row in the AI section above, for the concrete consumers of this widen. |
| Identity | Approvals | approval_request.initiator_actor_id (RENAMED from requested_by_actor_id, now NOT NULL — closing the null-approver bypass bug) / .resolved_by_actor_id (nullable), approval_step.assigned_approver_actor_id/.acted_by_actor_id, plus every other *_actor_id column across all 8 tables → identity.actor.id (enforced FKs) |
Actor attribution for the autonomy-first pattern. initiator_actor_id/.resolved_by_actor_id moved here from the Identity → Admin row above (Admin's own row now covers only tenant_setting/api_key). |
| Identity | Approvals | Read-only consumer of identity.agent_duty_grant — no new authority mechanism introduced |
An agent's ability to initiate or act on an approval step is gated the same way every other module's agent actions are, via a module_code='approvals' duty grant. trg_approval_step_blocks_agent_approver additionally enforces the C8 financial-autonomy boundary at the DB layer (a workflow-level blocks_agent_approver flag) regardless of grant state — an agent-type actor's decision is rejected outright on such a workflow, live-reproduced (agent rejected, human succeeds). |
| Platform (internal) | Approvals | platform.uuid_generate_v7() as the PK default on approval_event only (the module's one genuinely append-only table; the other 7 use the codebase's standard PK strategy) |
Matches Remediation Phase 2's rule for genuinely append-only ledger tables (PROJECT_DECISIONS #38). |
| Platform (internal) | Approvals | platform.reject_append_only_mutation() — REUSED (already defined, already consumed by 9 tables/6 schemas before this build) + REVOKE UPDATE, DELETE FROM authenticated on approval_event |
Append-only audit-log enforcement, live-reproduced: both UPDATE and DELETE rejected, even as the Postgres superuser. |
| Platform | Approvals | platform.outbox — NOT wired, disclosed and deliberate. approval_delivery is a separate, human-facing approver-notification-attempt table, not a domain-event producer/consumer. |
approval_delivery (outbound approver notifications: email/in_app/sms, pending/sent/delivered/opened/failed) was a disclosed, TEMPORARY duplication of a slice of the not-yet-built Notifications module's own planned "delivery attempts" scope — RECONCILED 2026-07-19 now that notifications is actually built: per Ruling 15, approval_delivery becomes a thin cache written by NotificationsService rather than an independently-maintained duplicate. See the Notifications section below for the actual seam. Not a duplicate of platform.outbox either way (different audience: outbox is domain-facing state-change events). |
| All modules | Approvals | Every table carries tenant_id → platform.tenant.id (enforced FK) |
Standard tenant-scoping, same as every other module. |
The C8 financial-autonomy boundary, now DB-enforced at the approval layer: approval_workflow.blocks_agent_approver (default false) plus trg_approval_step_blocks_agent_approver mean a workflow can be marked so that no agent-type actor may ever resolve one of its steps — the column itself is write-locked against the tenant-scoped authenticated role. A real gap was found live during this build: the first-attempt column-level REVOKE UPDATE (blocks_agent_approver) FROM authenticated did not actually restrict anything (Postgres ACLs are additive across granularities — a column REVOKE cannot subtract from a broader table-level GRANT). Fixed by revoking the table-level UPDATE entirely and re-granting column-by-column on every column except blocks_agent_approver; re-tested live (the flip attempt now fails, an ordinary column on the same table stays writable).
Still forward-compatible, still not yet consumed by any other module — same disclosed state Admin's own engine was in before the move: purchasing keeps its own bespoke, unrelated purchase_order.approval_status; pricing/payments' review_status columns remain agent-anomaly review gates, a different shape/purpose, not tenant-approval routing. A number of other modules' own review/approval-shaped columns and tables (identity's access_request/sod_violation, crm's customer_tax_certificate/merge candidates, inventory's stock_adjustment_request/merge/count sign-off, ai's import_record, pos's sale_refund.approved_by_actor_id, purchasing's invoice/match/return gates) are logged in OPEN_ITEMS as future convergence candidates, each with its own concrete trigger — none decided or bundled into this build.
Pricing
pricing is the first module of the sell path, schema-locked 2026-07-06 (4 tables, 75 columns) — see PROJECT_DECISIONS #26. Schema-only so far — PricingService doesn't exist yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Pricing | CRM | price_rule.customer_id, price_list_assignment.customer_id / customer_group_id — now REAL, enforced FKs (confirmed live at pricing's 2026-07-06 lock, not forward-looking) |
Customer-specific and group price-list assignments resolved via CRM identity. |
| POS / Orders | Pricing | Hard Contract 1 (LOCKED, binding) — now SATISFIED by BOTH pos and orders (confirmed live at pos's 2026-07-07 lock, test C1, and again at orders' 2026-07-07 lock, test E1). PricingService.resolvePrice() at checkout / order confirmation resolves the line-item price; pos.sale_line AND orders.order_line both snapshot ALL of the following at the moment of sale/order confirmation, verbatim field-for-field: resolved_amount_minor_units (bigint, NOT NULL — the PRE-rounding resolved amount, an exact integer count of the currency's smallest unit), charged_amount_minor_units (bigint, NOT NULL — the actual amount charged after any PricingService display-rounding; equal to resolved_amount_minor_units when no rounding applied), currency_code (char(3)), tax_treatment (text), resolving_price_rule_id (nullable FK, for traceability — NULL when no rule matched and bare base_price_cents was used), resolved_quantity (the quantity the price was resolved against, needed to verify a min_qty-tier rule applied correctly). This is the authoritative historical record of "what did the customer actually pay" — distinguishing the resolved-vs-charged amount closes the "$9.99 displayed, $9.97 charged" ambiguity. NOT satisfied by price_rule's own supersede-based history alone. See PROJECT_DECISIONS #26, #29. |
Pricing hard-contracts seam note: the POS/Orders → Pricing row above is 1 of 3 binding Hard Contracts established at pricing's 2026-07-06 lock (Hard Contract 1, shown in full above; Hard Contract 2 — one versioned resolvePrice() spec + cross-language Node/Dart golden test vectors with a named minimum coverage bar; Hard Contract 3 — display rounding is a PricingService-only concern, never a schema mutation, reconciled with Contract 1's pre/post-rounding amount distinction). Full binding text for all 3 lives in module_spec/pricing.md's Build Requirements section — this file only restates Contract 1 in full because it is the one that is itself a cross-module seam (POS/Orders ↔ Pricing); Contracts 2 and 3 govern PricingService's own internal build discipline and are referenced here, not restated.
CRM
crm is the first PRODUCT module built in v2 (modules #1–4 — platform/identity/shared/multi_loc — were foundation/service-layer; crm is the first of the 13 nursery-vertical product modules). 13 tables, 193 columns as of Remediation Phase 4 (up from 189, see PROJECT_DECISIONS #39/#40), schema-locked 2026-07-06. Seams below are crm's OUTBOUND dependencies (see the existing Pricing section above for the INBOUND seam — pricing.price_rule.customer_id / price_list_assignment.customer_id / customer_group_id — which already documents Pricing → CRM from Pricing's side).
| From | To | Mechanism | What flows |
|---|---|---|---|
| CRM | Shared | crm.address.country_code / crm.address.region_code → shared.country.iso_alpha2 / shared.administrative_region.iso_3166_2 (enforced FKs). Same pattern on crm.customer_tax_certificate.issuing_country_code / issuing_region_code. |
Global jurisdiction validation for addresses and tax-certificate issuing jurisdiction — mirrors multi_loc.site's exact precedent (DR-37), including the region/country consistency CHECK. |
| CRM | Multi-Location | crm.customer_note.site_id / crm.customer_task.site_id → multi_loc.site.id (enforced FKs, nullable). |
Identifies which site a staff/agent interaction (note) or follow-up (task) belongs to for multi-site tenants. Deliberate, narrow exception to "master data doesn't carry site_id" (DR-39) — these are event/interaction records, not master data. |
| CRM | Identity | Every *_by_actor_id / *_actor_id column across all 13 crm tables (e.g. created_by_actor_id, updated_by_actor_id, computed_by_actor_id, verified_by_actor_id) → identity.actor.id (enforced FKs). |
Actor attribution for the full autonomy-first pattern (human or AI actor on every mutation), reusing the canonical pattern established by the 2026-07-06 autonomy-first backfill (PROJECT_DECISIONS #19) and consumed here for the first time by a product module. |
| CRM | Consumer | crm.customer.consumer_id — plain nullable UUID, NO FK (DEFERRED forward-ref, DR-34). |
The Model B bridge, deferred. Verified live that the consumer schema does not exist in v2 today. See the STALE-DOCUMENTATION CORRECTION note in the Consumer section below, which this same crm build revealed and corrected — the Consumer section previously (and incorrectly) described this as "FK — READY." Trigger to wire the real FK: when consumer is designed/built in v2 (crm OPEN_ITEMS #2). |
| CRM | agent_duty_grant | crm introduces NO new authority mechanism — it is a pure consumer of identity.agent_duty_grant (PROJECT_DECISIONS #22). Autonomous crm actions (enrich, compute segment, propose/execute merge, create task) check for an active grant scoped to module_code='crm' permission codes (e.g. crm:customer:enrich, crm:customer_merge:execute). Enforcing service methods not yet built (schema-only this pass). |
Authority-check seam. crm is the first module where spend_limit_cents is essentially always NULL — its autonomous actions are volume-bounded (quantity_limit), not money-bounded. |
| Shared | CRM | customer.payment_terms_id → shared.payment_terms_catalog.id (nullable FK, Remediation Phase 4 Item 17b, PROJECT_DECISIONS #40, additive-interim alongside the unchanged credit_terms CHECK-enum — NOT yet kept in sync, and the 2 vocabularies are NOT a clean subset of each other either, see OPEN_ITEMS) |
The catalog represents complex terms (e.g. "2/10 net 30") the bare enum cannot. |
| CRM (internal) | CRM | customer.pii_vault_ref (Remediation Phase 4 Item 18, PROJECT_DECISIONS #40, nullable text) mirrors platform.tenant_profile.ein_ref's vault-reference pattern — shares (does not duplicate) that same OPEN_ITEMS vault-service dependency. |
Enables future crypto-shred GDPR/CCPA erasure once a vault-encryption service exists; schema-only today. |
crm boundary note (GUARD) — what crm explicitly does NOT own:
- Charge accounts / credit — CORRECTED 2026-07-08, Remediation Phase 3 Item 8 (PROJECT_DECISIONS #39): the prior claim here — that
credit_terms/credit_limit_centswere REMOVED fromcrm.customerentirely per DR-35 — was itself the bug.crm.customerOWNScredit_limit_cents/credit_terms(credit-worthiness is a relationship judgment about THIS customer, crm's domain);billing.ar_accountREADS them, never duplicates them (see the Billing section above for this seam from Billing's side;billing.ar_account's own comment was already correct before this fix — crm's was the lie). Service-layer enforcement (blocking a charge that would exceed the limit) is deferred — see OPEN_ITEMS. - Loyalty — points/tiers/redemption belong to Rewards, not crm. Note: the existing Rewards section further down this file claims its Consumer seam is "DONE at rewards lock 2026-06-12" — that is stale v1/pre-pivot documentation;
rewardshas not actually been built in v2 as of this crm lock. Flagged in the Rewards section itself. - Discount / promo — coupon and promo-code mechanics belong to Offers, not crm. Note: the existing Offers section further down this file has the same staleness — its Consumer seam claims "DONE at offers lock 2026-06-12" but
offershas not actually been built in v2. Flagged in the Offers section itself. - Campaign send mechanics — crm owns consent (
crm.customer_consent,customer.marketing_opt_in) per the existing consent-is-CRM's standing pattern above, but the actual sending of campaigns/messages is Notifications' domain. crm records whether contact is permitted; Notifications executes the send.
Inventory
inventory is the biggest module built so far. Its 2026-07-06 lock made it the first real consumer of the global plant reference table (now nursery_ref.plant, at the time still part of shared); that plant-taxonomy linkage (item.plant_id) was extracted out of the vertical-neutral core entirely on 2026-07-18 (Phase 2, PROJECT_DECISIONS #70) — see the new Nursery / Nursery Reference sections below. 24 tables, 338 columns as of Remediation Phase 4 (up from 337, see PROJECT_DECISIONS #40), schema-locked 2026-07-06. Remediation Phase 3, Item 11 (2026-07-08, PROJECT_DECISIONS #39): stock_movement.movement_type widened to also accept 'produced' (already-permitted source_module='production' now has a real movement_type to pair with it — a nursery propagating its own stock). Pure enum-widen, zero column change. Remediation Phase 4, Item 20b (2026-07-08, PROJECT_DECISIONS #40): stock.last_movement_id (+1 col, nullable reconciliation watermark FK → stock_movement) + the codebase's FIRST CREATE VIEW, stock_reconciliation_shell — deliberately a minimal, aggregation-free shell (plain LEFT JOIN); the real sign-aware drift-detection join is a named, deferred follow-up (see OPEN_ITEMS). The view does not count toward the table total. Seams below are inventory's OUTBOUND dependencies (see the existing POS, Orders, and Purchasing sections above for the INBOUND seams — InventoryService.completeSale(), InventoryService.reserve(), InventoryService.receiveStock(source_module='purchasing') — which already document those modules → Inventory from their own side).
| From | To | Mechanism | What flows |
|---|---|---|---|
| Inventory | Shared | item_variant.sell_uom_code / stock_uom_code (ON DELETE RESTRICT) / purchase_uom_code / weight_uom_code (ON DELETE SET NULL) + inventory_location.capacity_uom_code (ON DELETE SET NULL) → shared.unit_of_measure.code; item_variant.currency_code → shared.currency.iso_code (ON DELETE RESTRICT). item.plant_id (the FK into plant.id, the global plant reference table, then owned by shared) REMOVED, 2026-07-18 (PROJECT_DECISIONS #70) — column dropped entirely (0 of 1,735 live rows had it set); see the new Nursery section below. |
Global UOM/currency validation for sellable/stockable/purchasable quantities and pricing, reusing multi_loc's and crm's exact precedent. |
| Inventory | Multi-Location | inventory_location.site_id, stock.site_id, stock_movement.site_id, stock_reservation.site_id, stock_count.site_id, stock_lot.site_id, stock_adjustment_request.site_id → multi_loc.site.id (all enforced FKs). |
Every physical-stock-bearing and stock-event table is site-scoped for multi-site tenants — confirmed live: 7 tables carry the FK. |
| Inventory | Identity | Every *_by_actor_id / *_actor_id column across inventory's 24 tables (e.g. created_by_actor_id, updated_by_actor_id, reviewed_by_actor_id, performed_by_actor_id, proposed_by_actor_id, merged_by_actor_id, counted_by_actor_id, started_by_actor_id, reconciled_by_actor_id) → identity.actor.id (enforced FKs). |
Actor attribution for the full autonomy-first pattern (human or AI actor on every mutation), reusing the canonical pattern established by the 2026-07-06 autonomy-first backfill (PROJECT_DECISIONS #19) and already consumed by crm — inventory's turn now. Confirmed live: 29 FKs to identity.actor across the schema. |
| Inventory | agent_duty_grant | inventory introduces NO new authority mechanism — it is a pure consumer of identity.agent_duty_grant (PROJECT_DECISIONS #22), same as crm (PROJECT_DECISIONS #23). Autonomous inventory actions (propose/execute a stock adjustment, propose/execute an item merge, draft a reorder suggestion) check for an active grant scoped to module_code='inventory' permission codes (e.g. inventory:stock_adjustment:propose, inventory:stock_adjustment:execute, inventory:item_merge:propose, inventory:item_merge:execute). Enforcing service methods not yet built (schema-only this pass; InventoryService doesn't exist yet). |
Authority-check seam. Unlike crm (where spend_limit_cents is essentially always NULL), inventory is the first module where it's expected to actually be used: stock_adjustment_request.estimated_impact_cents exists specifically to let agent_duty_grant.spend_limit_cents bound a stock-adjustment proposal's balance-sheet impact. |
| Inventory | Files (deferred) | stock_movement.photo_ref — plain nullable uuid, NO FK (confirmed live: no FK constraint on the column). Same deferred-forward-ref treatment as crm.customer.consumer_id. |
Receiving/count photo evidence, deferred until the files schema is built — verified live via \dn that files does not exist in v2 today (only identity, multi_loc, platform, shared, crm, inventory). Trigger to wire the real FK: when the files module is built. |
| Returns | Inventory | returns.return_receipt_line.stock_movement_id/.stock_movement_line_id → inventory.stock_movement/.stock_movement_line (composite FKs, nullable), posted atomically via returns.post_and_cap_return_receipt_line() (derive-and-cap, BLOCK-not-flag over-tolerance) — never a direct inventory.stock write. stock_movement.source_module CHECK widened 2026-07-11 (PROJECT_DECISIONS #61) to also accept 'returns'. New — see the dedicated Returns section below for the full seam. |
A processed return receipt posts a capped, idempotent movement_type='returned' stock movement, honoring the same "Inventory owns stock mutation exclusively" guard every other stock-posting module carries. |
Pricing seam (built, 2026-07-06): Inventory owns item_variant.base_price_cents (list-price anchor, confirmed live as bigint not null) and avg_cost_cents (weighted-average costing, confirmed live as bigint not null default 0) — these are the anchors pricing.price_rule now actually consumes: avg_cost_cents is the live join target for price_type='cost_plus_percent' markup pricing (resolved fresh at resolution time, never cached), and item_variant.currency_code is the point-in-time source price_rule.currency_code snapshots from at INSERT time. Pricing (schema-locked 2026-07-06, see PROJECT_DECISIONS #26) resolves the actual sale price via the existing Pricing section's PricingService.resolvePrice() pattern used by POS/Orders, and owns markdown execution entirely; inventory only surfaces dead/aging-stock signals (stock.last_movement_at) for Pricing to act on. PricingService itself doesn't exist yet (schema-only so far).
Purchasing seam (deferred, not built): stock_movement.source_module already anticipates Purchasing in its CHECK constraint (confirmed live: chk_stock_movement_source_module CHECK (source_module = ANY (ARRAY['pos','orders','purchasing','inventory','production','system']))), and the existing Purchasing section above already documents InventoryService.receiveStock(source_module='purchasing') as Purchasing's outbound seam into Inventory. No live seam row is added here since purchasing is not yet a schema in v2 — this entry just confirms Inventory's side is forward-compatible and ready.
Nursery-vertical seam (MOVED, 2026-07-18, PROJECT_DECISIONS #70): the item.plant_id FK into plant.id (the global plant reference table, then owned by shared) is gone — dropped, not deferred. Nursery-vertical plant linkage is now nursery.item_profile.item_id → inventory.item(id, tenant_id), a tenant-scoped extension table that points INTO Inventory rather than a column ON inventory.item — see the new Nursery / Nursery Reference sections below for the full seam.
inventory boundary note (GUARD) — what inventory explicitly does NOT own:
- Actual sale price / markdown execution — belongs to Pricing (unbuilt). Inventory only owns the anchors (
base_price_cents,avg_cost_cents) and surfaces aging-stock signals; it does not resolve or execute price changes. - Purchase order lifecycle — belongs to Purchasing (unbuilt). Inventory only records the resulting stock movement when goods are received.
- Kit BOM edits by agents — per the D7 autonomy-boundary ruling, kit-component edits are human-only (
neverauthority), never delegated to an agent. - Plant taxonomy / growing-environment data — belongs to the new
nursery/nursery_refschemas (Phase 2, PROJECT_DECISIONS #70). Inventory owns zero plant-taxonomy columns as of this phase.
Nursery
nursery is a NEW schema (tenant-scoped vertical extension, schema-locked 2026-07-18, Phase 2 of the Nursery Vertical Extraction, PROJECT_DECISIONS #70) — holds nursery-specific profile data extracted OUT of the vertical-neutral core, per the governing principle now codified as SCHEMA_CONVENTIONS.md §21 (a generic core table must never gain a vertical-only column/FK when a tenant-scoped extension table can represent the same relationship). 2 tables: item_profile (replaces the dropped inventory.item.plant_id), site_profile (replaces the dropped multi_loc.site.climate_zone_code + 3 nursery-only site_type values). FK direction confirmed correct: nursery.item_profile/site_profile point INTO inventory.item/multi_loc.site — the core never points back. Schema-only so far — no NurseryService yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Nursery | Inventory | nursery.item_profile.item_id → inventory.item(id, tenant_id) (composite FK, partial UNIQUE(tenant_id, item_id) WHERE deleted_at IS NULL). REPLACES inventory.item.plant_id (the old FK into plant.id, then owned by shared), dropped entirely 2026-07-18 (PROJECT_DECISIONS #70) — see the retired row in the Inventory section above. |
Links a tenant's inventory item to its nursery-vertical profile (plant linkage), keeping the vertical-neutral inventory.item free of plant-taxonomy columns. |
| Nursery | Nursery Reference | nursery.item_profile.plant_id → nursery_ref.plant.id (plain FK, nullable — nursery_ref is non-tenant-scoped, so no composite target exists). |
Optional botanical taxonomy link for a plant-typed item profile. |
| Nursery | Multi-Location | nursery.site_profile.site_id → multi_loc.site(id, tenant_id) (composite FK, partial UNIQUE(tenant_id, site_id) WHERE deleted_at IS NULL). REPLACES multi_loc.site.climate_zone_code + 3 nursery-only site_type values, dropped/narrowed entirely 2026-07-18 (PROJECT_DECISIONS #70). |
Links a tenant's site to its nursery-vertical growing-environment profile (hardiness zone, growing-environment attributes). |
| Nursery | Nursery Reference | nursery.site_profile.hardiness_zone_id → nursery_ref.climate_zone.code (plain FK, nullable). |
Hardiness-zone reference for a site's growing profile. |
| Identity | Nursery | Every *_by_actor_id column across both tables → identity.actor.id (enforced FKs). |
Actor attribution for the full autonomy-first pattern (PROJECT_DECISIONS #19). No new authority mechanism — pure consumer of identity.agent_duty_grant. |
Nursery Reference
nursery_ref is a NEW schema (global, non-tenant-scoped, Vrida/AI-curated, read-only-by-default like shared; schema-locked 2026-07-18, Phase 2, PROJECT_DECISIONS #70) — climate_zone/plant/plant_common_name/plant_climate_zone moved verbatim out of shared via ALTER TABLE ... SET SCHEMA (data/indexes/constraints/triggers preserved; Postgres FK constraints track the referenced table by OID, not schema-qualified name, so every pre-existing FK into these 4 tables kept resolving correctly across the move with zero FK-side change). Write-locked down to authenticated (REVOKE INSERT/UPDATE/DELETE + ALTER DEFAULT PRIVILEGES), same posture as shared. Every consumer of the moved tables was retargeted in the same migration — see the retired row in the Shared section below and the retargeted row in the AI section below.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Nursery Reference | Shared | nursery_ref.plant_common_name.locale_code → shared.locale.code (plain FK). Foundation-to-foundation seam (both non-tenant-scoped globals) — carried over unchanged from when this table lived inside shared itself; now cross-schema. |
Locale validation for a plant's localized common name. |
| Identity | Nursery Reference | nursery_ref.plant/plant_common_name/plant_climate_zone.created_by_actor_id → identity.actor (nullable FK) — MOVED, 2026-07-18 (PROJECT_DECISIONS #70), retired from the Shared section below (originally introduced by the 2026-07-06 autonomy-first backfill, PROJECT_DECISIONS #19). |
Actor attribution for AI/human-authored plant catalog rows. |
See the AI section below for AIService's own write seam into nursery_ref.plant/nursery_ref.plant_common_name — retargeted in place, 2026-07-18 (PROJECT_DECISIONS #70), not duplicated here.
Notifications
notifications is now schema-locked (2026-07-19, module #29, PROJECT_DECISIONS Notifications entry) — 18 tables / 252 cols, the first STALE-BUT-UNBUILT module revival (SCHEMA_DESIGN_RUNBOOK 2.1a). The seams below supersede the pre-build placeholder rows this section previously held (which assumed an integrations/audit module neither of which exist or are needed for this build — NotificationsService will talk to Resend/Twilio directly, mirroring PaymentsService's own established direct-to-Stripe precedent, and provider_event_log/delivery_attempt serve the compliance-trail role interimly). Schema-only so far — no NotificationsService yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Notifications | Platform | notification.outbox_event_id → platform.outbox(id, tenant_id) (composite FK, Ruling 3 — the outbox-riding seam); delivery_attempt.provider/provider_event_log.provider_code/provider_event_dead_letter.provider_code → platform.processor_catalog.code (Ruling 4, post-widen — processor_catalog.kind CHECK widened to add 'notification_provider', resend/twilio seeded) |
Transactional-event sends ride the existing outbox; the provider catalog is the single source of truth for valid delivery providers across payments AND notifications. |
| Notifications | CRM | campaign.customer_group_id → crm.customer_group(id, tenant_id) (composite FK — required the emergency crm companion reopen adding customer_group's own UNIQUE(id,tenant_id)); campaign.customer_segment_definition_id → crm.customer_segment_definition.id (bare FK + trg_campaign_validate_segment, mirroring offers.validate_offer_targeting_rule_segment() exactly — customer_segment_definition.tenant_id is nullable); every *_customer_id → crm.customer.id. Consent itself is still queried from CRM at send-time (service-layer, not yet built), Notifications owns ZERO consent tables. |
Campaign audience targeting (group + segment), recipient resolution, and the consent gate. |
| Notifications | Identity | Every *_actor_id → identity.actor.id (standard actor-attribution convention). |
Autonomy-pack attribution + human actor tracking across notification_template/notification/in_app_notification/campaign/inbound_message/suppression. |
| Notifications | Offers | campaign.offer_id → offers.offer(id, tenant_id) (composite FK). |
A campaign can carry an attached offer for its recipients. |
| Notifications | Purchasing | inbound_message.vendor_id → purchasing.vendor(id, tenant_id) (composite FK) — the vendor-reply-attribution seam. |
Two-way SMS/email replies from a vendor route back through inbound_message, closing half of the PO-acknowledgement gap. |
| Purchasing | Notifications | purchase_order.vendor_acknowledged_at/.vendor_acknowledgement_note (companion reopen, no FK — the write-back half of the PO-acknowledgement seam, notifications sends the request, the vendor's actual acknowledgement lands directly on the PO). |
Closes the round-trip: PO sent (purchase_order.status='sent') → vendor notified (notifications) → vendor acknowledges (written back to purchasing.purchase_order directly, not routed back through Notifications). |
| Orders | Notifications | orders.order_fulfillment gained UNIQUE(id, tenant_id) (companion reopen) — prerequisite only, no live FK yet. The pickup-ready notification seam stays a polymorphic notification.source_module/.source_ref (no reciprocal column on Orders' side), matching every other source_ref seam in this schema. |
Future-proofs a real composite FK for the pickup-ready notification without committing to one before a concrete need. |
| Billing | Notifications | billing.ar_statement gained UNIQUE(id, tenant_id) (companion reopen) — prerequisite for the seam below. |
Same future-proofing as Orders. |
| Notifications | Billing | Convention note, no schema change (Ruling 15). ar_statement.sent_at/.delivery_method become a thin cache written by NotificationsService once real notification/delivery_attempt rows exist for statement-delivery sends — Billing does not independently duplicate delivery state. |
Statement-delivery status becomes sourced from Notifications' own ledger, not tracked twice. |
| Notifications | Approvals | Convention note, no schema change (Ruling 15). approvals.approval_delivery's own send-tracking becomes a thin cache written by NotificationsService, the same treatment as billing.ar_statement — reconciles OPEN_ITEMS row 275's disclosed TEMPORARY duplication (see the Platform/Approvals row above) now that the real Notifications module exists. |
Approver-notification delivery status is sourced from one place going forward, not duplicated between approval_delivery and a hypothetical future Notifications table. |
Integrations
| From | To | Mechanism | What flows |
|---|---|---|---|
| Integrations | Admin | connector.integration_config_id → admin.integration_config (FK); AdminService writeback of last_sync_at / last_sync_status |
Runtime-to-config anchor; sync status written back to Admin for display. |
| Integrations | Admin (webhooks) | webhook_delivery.webhook_config_id → admin.webhook_config (FK) |
Outbound webhook delivery references the config in Admin. |
| Integrations | Notifications | connector_webhook_event.routed_to = 'notifications.inbound_message' |
Twilio inbound SMS routed from Integrations to NotificationsService for processing. |
Files
files is now schema-locked (2026-07-11, module #26, PROJECT_DECISIONS #58/#59) — 6 tables / 89 cols. The 8 forward-ref columns below are all still plain columns, no FK yet — wiring them to real composite (col, tenant_id) → files.file(id, tenant_id) FKs is a dedicated follow-up (a 6-module coordinated reopen), now unblocked since files.file carries UNIQUE(id, tenant_id) from this build's own day one (see OPEN_ITEMS). files.attachment (new) is the polymorphic many-to-many join for any NEW file relationship going forward — it coexists with, not replaces, the 8 single-column forward-refs below.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Admin | Files | tenant_branding.logo_ref, compliance_document.document_ref → files.file.id (deferred forward-ref, no FK yet) |
Brand logo (public), compliance PDFs (private). |
| CRM | Files | customer_tax_certificate.document_ref → files.file.id (deferred forward-ref, no FK yet) |
Tax cert PDFs (private). |
| POS | Files | sale.signature_ref → files.file.id (deferred forward-ref, no FK yet; guarantee table doesn't exist) |
Canvas signature PNGs (private). |
| Receiving | Files | goods_receipt.shipment_photo_ref → files.file.id (deferred forward-ref, no FK yet — RENAMED with the table from purchasing.purchase_receipt.shipment_photo_ref at the 2026-07-10 Receiving extraction, PROJECT_DECISIONS #55). A real, disclosed limitation: this column caps damage documentation to ONE photo per receipt — files.attachment could resolve this (many-to-many), not fixed this pass. |
Receiving-dock photos (private), once wired. |
| Integrations | Files | sync_run.file_ref → files.file.id |
Import files / Picas CSV (private) — integrations module not yet built. |
| Audit | Files | audit_export_job.export_ref, data_subject_request.response_artifact_ref, dpa_agreement.document_ref → files.file.id |
Audit exports (signed, 7yr retain), DSR packages (signed + file_access_grant), signed DPAs (private) — audit module not yet built. |
| Inventory | Files | item_image.file_id → files.file.id (deferred forward-ref, no FK yet) |
Product images (public). |
| AI | Files | import_file.file_id → files.file.id (deferred forward-ref, no FK yet — confirmed live .notNull(), contradicting its own code comment claiming nullable, a pre-existing ai-module bug logged to OPEN_ITEMS, unrelated to this build) |
Uploaded import files. |
| Files | Consumer | consumer.get_files_for_consumer(p_consumer_id uuid) — a consumer-schema-owned, parameter-scoped SECURITY DEFINER function reading files.file WHERE consumer_id = p_consumer_id. No table-level FK (Files is foundation-layer, must not FK into consumer); files grants zero USAGE to consumer_authenticated at all. |
A consumer's own receipt/document files, across every merchant they've transacted with. |
| Files | Platform | tenant_storage_usage (usage counter, RENAMED from file_storage_usage) vs platform.tenant_entitlement (tier limit — 2 new keys + an int4→bigint width fix, both deferred, see OPEN_ITEMS) |
Storage quota enforcement: usage tracked in Files; limit owned by Platform. |
| Returns | Files | files.attachment.entity_type CHECK widened 2026-07-11 (PROJECT_DECISIONS #61) to accept 'return_authorization' — the polymorphic files.attachment many-to-many join can now attach a file to a returns.return_authorization row (no column added on Returns' own side). Separately, returns.warranty.signature_ref → files.file.id is a new deferred plain-column forward-ref (no FK yet), folded into the existing Files FK-wiring bundle below alongside the 8 forward-refs already there. |
A return authorization can carry attached evidence files (damage photos, signed RMA forms) once FilesService/ReturnsService exist; a warranty claim's signature capture waits on the same not-yet-executed FK-wiring bundle every other pre-Files-lock forward-ref is waiting on. |
Platform R2 exception: platform.tenant_data_lifecycle.download_url and platform.agreement_version.document_url are Platform-managed R2 refs, NOT routed through FilesService. platform.agreement_acceptance.signature_ref is a HelloSign envelope ID, not R2. platform.subscription_invoice.invoice_pdf_url is Stripe-hosted. These are documented exceptions because the files are either legal/billing artifacts owned entirely by Platform or externally hosted — routing them through FilesService would add an unnecessary dependency and obscure the ownership boundary.
Storage architecture: Cloudflare R2 is the sole storage of record; AWS S3 is transient staging ONLY for AWS Textract's async extraction path. See module_spec/files.md §4 and PROJECT_DECISIONS #58 for the full rationale + cost model.
AI
Reopened 2026-07-16 (agents-v2/v3 build, Phase 5 of 6 — the agents module build itself): ai.agent_execution gained 8 columns (agent_task_id, sequence_index, tool_version_id, approval_request_id, retry_count, blocked_by_policy, confidence_threshold, escalation_reason), wiring the AI call-log ledger into the new agents orchestration layer and the approvals engine — see the 3 new rows below, plus the closed routing_policy.workload_class_id forward-ref row. See the new Agents section further down this file for the seams these same columns produce from that module's own side.
| From | To | Mechanism | What flows |
|---|---|---|---|
| AI | Identity | ai_request.agent_identity_id, agent_execution.agent_identity_id, agent_usage_period.agent_identity_id → identity.agent_identity.id (enforced FK, verified live on all 3 tables) |
Every LLM call, every logged agent action, and every usage-period rollup is attributed to a real agent identity. |
| AI | Identity | agent_execution.permission_id → identity.permission.id (enforced FK, nullable — verified live) |
The specific permission an agent action was exercised under, when applicable (not every logged action maps to a discrete permission). |
| AI | Identity | import_job.created_by_actor_id, import_record.reviewed_by_actor_id, agent_memory.created_by_actor_id, agent_memory.updated_by_actor_id → identity.actor.id (enforced FK, verified live on all 4 columns) |
Actor-attribution — who created the import job, who reviewed an import record, who created/last-touched a memory entry. Same actor-attribution convention every other locked module uses. |
| AI | agent_duty_grant | agent_execution.authority_level_applied — plain text column, no FK (verified live: not a foreign key) |
Point-in-time snapshot of what identity.agent_duty_grant.authority_level applied when the action was taken. Deliberately NOT a live reference — a duty grant can change or be revoked later without rewriting history; the execution ledger must show what authority governed the action at the time, not what governs it now. |
| AI | Files (deferred) | import_file.file_id — plain uuid column, no FK (verified live: files schema does not exist in the database; \dn shows no files schema) |
Uploaded import files are meant to live in R2 via a future Files module. Until files is built, this is a forward-reference only — AI never stores file bytes in Postgres, and the column has nothing to resolve against yet. |
| AI | Agents | CLOSED 2026-07-16 (agents-v2/v3 build Phase 5) — was a forward-ref since Phase 2 (2026-07-13, PROJECT_DECISIONS #63). routing_policy.workload_class_id → agents.workload_class.id (bare FK — workload_class is global/non-tenant, confirmed live) — wired the same day this phase landed the target table; pre-migration orphan audit found 0 violating rows. |
Scopes a routing policy to a workload class (e.g. high-stakes vs. low-stakes agent tasks), per E8 — no longer a forward-reference. See docs/open-items/OPEN_ITEMS.md (row now closed). |
| AI | Agents | NEW 2026-07-16 (agents-v2/v3 build Phase 5). agent_execution.tool_version_id → agents.tool_version.id (bare FK — tool_version is global/non-tenant, no composite target exists). |
Which exact, version-pinned tool an AI-logged execution invoked — a real reference to agents' own A2b version-row pattern instead of free text. |
| AI | Agents | NEW 2026-07-16 (agents-v2/v3 build Phase 5). agent_execution.agent_task_id → agents.agent_task.id (composite FK). |
Which orchestration-level task (agents' own unit of work) a given AI-layer execution row belongs to — the link between the AI call-log ledger and agents' own task-claim/fencing-token machinery (Guard E3). |
| AI | Approvals | NEW 2026-07-16 (agents-v2/v3 build Phase 5). agent_execution.approval_request_id → approvals.approval_request.id (composite FK). |
An AI execution that required human sign-off (a needs_approval-tier agent action) references the specific approval request that gated it, reusing Approvals' existing polymorphic engine rather than a second one. First seam between ai and approvals. |
| AI | Nursery Reference | AIService writes nursery_ref.plant (data_source='ai_generated', is_verified=false) + nursery_ref.plant_common_name aliases via service_role. Retargeted 2026-07-18 (PROJECT_DECISIONS #70) — was the plant/plant_common_name tables, then owned by shared, before the Nursery Vertical Extraction moved them out via ALTER TABLE ... SET SCHEMA; the mechanism itself is unchanged, only the schema name. |
Plant enrichment — AI writes enriched care content to the non-tenant-scoped plant reference table. Service-layer seam; no AI table. |
| AI | Notifications | AIService reads notifications.delivery_attempt.opened_at / clicked_at |
Send-time optimization — AI computes timing recommendation from engagement history, returns to NotificationsService. Service-layer read seam; no AI table. |
| AI | Inventory / CRM / Purchasing | import_record.target_module / target_table / target_row_id polymorphic backref; accepted rows loaded via owning module's service |
Accepted import rows are loaded into inventory.item, crm.customer, or purchasing.vendor via InventoryService / CRMService / PurchasingService. Polymorphic loose ref — NOT enforced FK (same pattern as audit_log.source_ref). |
| AI | Platform | AIService writes tenant_setup_task.completed_at + result on import completion |
Onboarding milestone closure — import_job.setup_task_code is a text seam to platform.tenant_setup_task.task_code. AI closes the milestone; Platform owns retry and milestone tracking. |
| AI | Platform | Aggregation job increments platform.tenant_usage_summary.ai_calls_count from ai_request rows |
AI call usage metering. Platform's tenant_entitlement (entitlement_code='ai_pack') owns the tier cap. Usage-here/limit-in-platform pattern (same as Files storage, Notifications quota). |
| CRM / Inventory | AI (agent_memory) |
decision_provenance.memory_refs — a JSONB array of ai.agent_memory.id values, documented on crm.customer, crm.customer_merge_candidate, crm.customer_merge, crm.customer_note, crm.customer_segment_membership, crm.customer_tax_certificate, crm.customer_task, and inventory.item, inventory.item_variant, inventory.stock, inventory.stock_adjustment_request, inventory.stock_count, inventory.item_merge_candidate (verified live: all carry a decision_provenance jsonb column) |
New seam, first real target for this key. memory_refs is the same documented-JSONB-key convention decision_provenance already uses everywhere (reference inside JSONB, not a column-level FK — same non-enforcement as import_record.target_row_id above) — but as of this build it resolves to an actual row: ai.agent_memory.id. Before this pass (crm's and inventory's own builds), the key existed in the docs with nothing on the other end to point to. decision_provenance.delegated_by_actor_id (G3) remains a documented-only key with no backing table — multi-agent handoff/delegation stays deferred. |
| AI (internal) | AI | agent_memory.subject_type/.subject_ref/.expires_at (Remediation Phase 4 Item 18, PROJECT_DECISIONS #40, chk_agent_memory_subject_consistency requiring both-null-or-both-set) — a GDPR/erasure-scoping seam, no FK (subject_ref is polymorphic, target type named by subject_type) |
Scopes a memory entry to the specific subject (e.g. a customer) it concerns, and gives it an expiry — not yet consumed by any erasure job. |
Semantics
semantics is a brand-new schema (agents-v2/v3 build, Phase 3 of 6), schema-locked 2026-07-14 — 14 tables / 114 cols, zero v1 antecedent, the shared business ontology the not-yet-built agents (Phase 5) and signals (Phase 4) modules will read from — see PROJECT_DECISIONS #64. Depends on no domain/nursery-vertical module, the same reverse-dependency discipline approvals established for itself. Schema-only so far — no SemanticsService yet, and semantics is not itself a consumer of identity.agent_duty_grant in this phase (it defines no agent-authored write path — see the module spec §8).
| From | To | Mechanism | What flows |
|---|---|---|---|
| Semantics | Platform | tenant_metric_binding.tenant_id/tenant_goal_binding.tenant_id/tenant_constraint_binding.tenant_id → platform.tenant.id (enforced FK, the RLS scope on all 3 tenant-scoped tables — the only 3 of semantics's 14 tables carrying a tenant_id at all). |
Tenant ownership for the 3 tenant-scoped, RLS-enabled binding tables. |
| Semantics | Platform | goal_definition.applies_to_domain → platform.module_catalog.id (enforced FK, nullable). |
Scopes a goal definition to a specific business domain/module (e.g. "protect cash" applying to purchasing/billing) via Platform's own module registry (built Phase 1, PROJECT_DECISIONS #62) — the first real consumer of module_catalog as an FK target outside platform itself. |
| Semantics | Identity | approved_function_registry.approved_by_actor_id (NOT NULL), tenant_metric_binding.owner_actor_id (nullable), tenant_goal_binding.approved_by_actor_id/tenant_constraint_binding.approved_by_actor_id (nullable) → identity.actor.id (enforced FK). |
Actor attribution — who approved a function for the registry, who owns a metric binding, who approved a goal/constraint binding. Same actor-attribution convention every other locked module uses. |
| Semantics (internal, deliberately bare) | multi_loc / platform | tenant_goal_binding.module_id/.site_id and tenant_constraint_binding.module_id/.site_id — plain uuid, NO FK (verified live against the migration's own DDL). |
A deliberate judgment call, not a deferred-FK debt of the ordinary kind: v3's own literal SQL declares these columns bare, with no REFERENCES clause specified, and no reconciliation-table row names them as FKs. platform.module_catalog already exists (unlike ai.routing_policy.workload_class_id's genuine forward-ref to the not-yet-built agents.workload_class), but the design of record does not specify wiring module_id to it, and no composite-FK prerequisite check was run for site_id either — left exactly as specified rather than over-interpreted. Logged in docs/open-items/OPEN_ITEMS.md (semantics | FK), not silently fixed or silently ignored. |
| agents (deferred, Phase 5) / signals (deferred, Phase 4) | Semantics | (forward — neither module built yet) agents/signals are expected to read metric_definition/.metric_version/.entity_definition/.dimension_definition/.goal_definition/.constraint_definition/.attribution_model_definition as the shared ontology vocabulary, and to write tenant_*_binding rows to set a tenant's authoritative metric/goal/constraint choices. |
The reason this module exists — a shared business-language layer so future agent decisions read a common vocabulary instead of each inventing its own. Not yet consumed — semantics has zero real callers today. |
The I2 approved-function-registry re-verification pattern (GUARD, internal to semantics but load-bearing for every future consumer). A metric/attribution-model version's computation is a pointer (resolved_function_reference/computation_reference) into approved_function_registry, never raw SQL in a data row — but the pointer alone only proves a function was ONCE approved, not that it still matches. semantics.verify_function_still_matches_approval() re-resolves and re-hashes the live function fresh on every call; trg_tenant_metric_binding_verify_function (BEFORE INSERT OR UPDATE on tenant_metric_binding) makes that check load-bearing at write time — closing the "approve a function, quietly redefine it later, bindings still trust it" supply-chain hole. Any future agents/signals consumer inherits this guarantee for free the moment it reads through tenant_metric_binding.
Signals
signals is a brand-new schema (agents-v2/v3 build, Phase 4 of 6, this build's own explicitly-designated HIGHEST-RISK phase), schema-locked 2026-07-15 — 11 tables / 111 cols, zero v1 antecedent, the feature/forecast/outcome store the not-yet-built agents module (Phase 5) will read from and write to — see PROJECT_DECISIONS #65. Depends on no domain/nursery-vertical module, the same reverse-dependency discipline approvals/semantics established for themselves. 4 of its 11 tables are partitioned (feature_value/forecast/anomaly_score weekly by recorded_at; outcome_observation monthly by created_at) — the other 7, including outcome_authority, are not. Schema-only so far — no SignalsService yet, and signals is not itself a consumer of identity.agent_duty_grant in this phase (it defines no agent-authored write path — see the module spec §8).
| From | To | Mechanism | What flows |
|---|---|---|---|
| Signals | Platform | Every tenant-scoped table's tenant_id → platform.tenant.id (enforced FK, verified live on all 7 tenant-scoped tables: feature_value, forecast, anomaly_score, experiment_assignment, experiment_exposure_event, outcome_observation, outcome_authority). |
Tenant ownership. Also the mechanism the new platform.current_tenant_id() helper (a thin wrapper over current_setting('app.current_tenant_id', true)::uuid, added this phase) reads from — the tenant-scoping GUC every RLS policy and tenantDB()/consumerDB() call already sets. |
| Signals | Semantics | outcome_observation.attribution_model_version_id → semantics.attribution_model_version.id (enforced FK, NOT NULL). |
The first real consumer of semantics's own attribution-model family — replaces v2's original free-text attribution_method column, exactly as semantics's own Phase 3 doc predicted this table would do. |
| Signals | Agents | CLOSED 2026-07-16 (agents-v2/v3 build Phase 5) — was a disclosed forward-ref since Phase 4 (2026-07-15, PROJECT_DECISIONS #65). outcome_observation.agent_action_id / outcome_authority.agent_action_id → agents.agent_action(id, tenant_id) (composite FKs, confirmed live) — wired the same day this phase landed the target table; pre-migration orphan audit found 0 violating rows in either table. Phase 4's own pre-existing signals-schema.spec.ts fixtures (5 placeholder agent_action_id literals) were retrofitted with a real minimal fixture chain (identity.actor→agent_identity→decision_context_snapshot→ai.agent_execution→agents.agent_decision→agents.agent_action) rather than left broken. |
An outcome observation/authority row now traces back to the exact agent action it measures — the reason signals exists in the first place, now a real reference instead of a design-of-record promise. See docs/open-items/OPEN_ITEMS.md (row now closed). |
| Signals | Agents | CLOSED 2026-07-17 (agents-v2/v3 build Phase 6, identity's 6th reopen) — was a disclosed deferred GRANT since Phase 4 (2026-07-15, PROJECT_DECISIONS #65). get_feature_as_of() / get_forecast_as_of() / get_anomaly_score_as_of() now have GRANT EXECUTE to the new agent_reader role (all 3, signature (uuid, uuid, timestamptz, timestamptz)). |
agent_reader, the intended caller per BLOCKER 1's own design, now exists and can call all 3 as-of functions — live-reproduced (EXECUTE succeeds for agent_reader, tenant-scoped correctly via platform.current_tenant_id()). No real call site yet — no AgentsService or other agent-execution read path exists. Logged in docs/open-items/OPEN_ITEMS.md (new row: agentReaderDB() has zero call sites). |
agents (schema built, AgentsService not built yet) |
Signals | (forward — service layer not built yet) agents is expected to write feature_value/forecast/anomaly_score via an elevated service connection (not authenticated — both are REVOKE ALL'd from that role), read them back through the 3 SECURITY DEFINER as-of functions — now real via agent_reader (Phase 6, 2026-07-17 — see the row above) — write outcome_observation rows measuring its own past actions' effect, and drive experiment_assignment/experiment_exposure_event for any decision that runs as a randomized experiment. agents.decision_context_snapshot's own future CREATE TRIGGER for signals.lock_experiment_causal_basis() (the function itself was built at Phase 4, touching only signals.experiment_assignment) also lands here. |
The reason this module exists — a bitemporal substrate so a future agent decision can prove what it knew, and when, rather than trusting a service-layer promise. Schema-level linkage now real (see the row above) and the read-boundary (agent_reader) now real too; service-layer consumption (an actual AgentsService) is still unbuilt — see OPEN_ITEMS. |
The SECURITY DEFINER as-of-function tenant-isolation mechanism (GUARD 1 / BLOCKER 1, internal to signals but load-bearing for every future consumer). feature_value/forecast/anomaly_score are REVOKE ALL'd from authenticated — the only sanctioned read path is 1 of the 3 SECURITY DEFINER as-of functions, each of which derives its tenant boundary from platform.current_tenant_id() (the session GUC), not from a caller-supplied parameter, closing a real tenant-spoof vector a naive SECURITY DEFINER design (p_tenant_id uuid + a WHERE tenant_id = p_tenant_id filter) would have left wide open — no argument position exists for a caller to substitute another tenant's ID into. Each function is owned by a new, minimal signals_function_owner role (NOLOGIN, not superuser, not the table owner) with its own narrow SELECT grant on exactly the 3 tables it reads. Any future agents consumer inherits this guarantee for free now that agent_reader has been granted EXECUTE on all 3 functions (Phase 6, 2026-07-17) — the tenant boundary is enforced inside the function, not left to the caller's own discipline.
The split-authority pattern (GUARD 2 / BLOCKER 2, internal to signals but load-bearing for every future consumer). outcome_observation.is_authoritative is a denormalized convenience flag only — the real "exactly one authoritative observation per (tenant, agent_action, outcome_type, measurement_window)" enforcement lives on the deliberately unpartitioned outcome_authority table's own PRIMARY KEY, maintained by a native, race-free INSERT ... ON CONFLICT ... DO UPDATE upsert. A native unique/partial-unique index on outcome_observation itself cannot express this guarantee once the table is partitioned by created_at — any future consumer reading "what's authoritative right now" for a scope must query outcome_authority, not outcome_observation.is_authoritative directly.
Agents
agents is module #28 (agents-v2/v3 build, Phase 5 of 6), schema-locked 2026-07-16 — 47 tables / 475 cols, the full v1 orchestration baseline (21 tables: agent_task, agent_thread, agent_event_log, agent_schedule, agent_trigger, agent_eval_suite, agent_eval_run, agent_eval_case, agent_performance_profile, agent_shadow_run, agent_shadow_decision, agent_autonomy_profile, agent_action, rollback_recipe, rollback_execution, tool_catalog [superseded by tool_definition/tool_version], agent_tool_grant, agent_catalog_entry, agent_catalog_entry_required_tool, tenant_agent_deployment, agent_incident) merged with v2's amendments (A1-A8) and v3's corrections (BLOCKER 1-8, I1-I8). Depends on Semantics (Phase 3) and Signals (Phase 4), the same reverse-dependency discipline those 2 modules established for themselves — agents is the consumer, not the other way around. This section documents the seams wired by this module's build across Phase 5 (2026-07-16, the module's own migration) and Phase 6 (2026-07-17, identity's 6th reopen — the phase that CLOSES the entire agents-v2/v3 build) — the module's own internal 47-table orchestration/eval/shadow/skill/kill-switch/policy architecture is out of this catalog's scope (see the migration file and Drizzle schema under packages/db/src/schema/agents/ for that). Schema-only so far — no AgentsService yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| AI | Agents | ai.agent_execution.tool_version_id → agents.tool_version.id (bare FK — tool_version is global/non-tenant, no composite target exists). NEW 2026-07-16. |
Which exact, version-pinned tool an AI-logged execution invoked — a real reference to agents' own A2b version-row pattern instead of free text. See the AI section above for this same seam from AI's own side. |
| AI | Agents | ai.agent_execution.agent_task_id → agents.agent_task.id (composite FK). NEW 2026-07-16. |
Which orchestration-level task a given AI-layer execution row belongs to — the link between the AI call-log ledger and agents' own task-claim/fencing-token machinery (Guard E3). See the AI section above. |
| AI | Agents | CLOSED 2026-07-16 — was a forward-ref since Phase 2 (2026-07-13, PROJECT_DECISIONS #63). ai.routing_policy.workload_class_id → agents.workload_class.id (bare FK — workload_class is global/non-tenant, confirmed live). Pre-migration orphan audit found 0 violating rows. |
Scopes a routing policy to a workload class (high-stakes vs. low-stakes agent tasks, per E8) — no longer a forward-reference. See the AI section above. |
| AI | Approvals | ai.agent_execution.approval_request_id → approvals.approval_request.id (composite FK). NEW 2026-07-16 — first seam between ai and approvals. |
An AI execution that required human sign-off (a needs_approval-tier agent action) references the specific approval request that gated it, reusing Approvals' existing polymorphic engine rather than building a second one. See the AI section above. |
| Agents | Approvals | approvals.approval_request.source_module CHECK (chk_approval_request_source_module) widened this same migration to also accept 'agents' (11 values total, up from the 10 the returns build left it at, PROJECT_DECISIONS #61) — no column added to approvals itself, same polymorphic source_ref-plus-CHECK pattern every other module's approval-routing seam uses. See the Approvals section above. |
An agent task/action/decision can request tenant-side approval routing (e.g. a needs_approval-gated skill activation or a high-risk tool call) via the existing engine — the schema-level door ai.agent_execution.approval_request_id above walks through operationally. |
| Signals | Agents | CLOSED 2026-07-16 — was a disclosed forward-ref since Phase 4 (2026-07-15, PROJECT_DECISIONS #65). signals.outcome_observation.agent_action_id / signals.outcome_authority.agent_action_id → agents.agent_action(id, tenant_id) (composite FKs, confirmed live). Pre-migration orphan audit found 0 violating rows in either table; Phase 4's own 5 placeholder test fixtures were retrofitted with a real fixture chain (identity.actor→agent_identity→decision_context_snapshot→ai.agent_execution→agents.agent_decision→agents.agent_action). |
An outcome observation/authority row now traces back to the exact agent action it measures — the reason signals exists in the first place. See the Signals section above. |
| Identity | Agents | Every *_actor_id column across the 47 agents tables → identity.actor.id (enforced FKs). The transitional Drizzle-barrel collision is now RESOLVED (Phase 6, 2026-07-17) — identity.agent_skill/agent_skill_assignment (the v1 tables) were DROPPED, so agents.agent_skill_assignment (I5's own destination table) is the sole surviving export; the coexistence Phase 5 disclosed no longer exists. |
Actor attribution for the full autonomy-first pattern (PROJECT_DECISIONS #19), consumed here for the first time by Agents — no new column or mechanism added to identity itself this phase. |
| Identity | Agents | NEW 2026-07-17 (agents-v2/v3 build Phase 6, identity's 6th reopen) — the identity→agents skill-catalog seam Phase 5's own comment called "coexists until Phase 6 drops the old one" is now REALIZED, not just planned. IdentityService's skill methods (listSkillCatalog, assignSkillToAgent, removeSkillFromAgent, listAgentSkillAssignments, and the skill-check half of authorizeAgentAction) now read agents.skill_definition/skill_version/agent_skill_assignment directly (service-layer join, no new FK — identity had no skill tables of its own left to FK from). Assignment is now at SKILL VERSION granularity (agents.agent_skill_assignment.skill_version_id), not skill-identity granularity; callers pass skillVersionId (renamed from skillId); listSkillCatalog's return shape flattened to {id, code, name, lifecycleStatus} (no category/moduleCode equivalent on the flatter agents.skill_definition catalog). |
IdentityService no longer owns any skill-catalog data of its own — it is a pure consumer of agents' own catalog, closing the last v1-carryover skill mechanism. Zero controllers/DTOs referenced any of the 4 methods before this change (grep-confirmed) — no live HTTP consumer affected. See footnote ²⁹ in MODULE_BUILD_STATUS.md and PROJECT_DECISIONS #67. |
Agents (deferred — no AgentsService yet) |
Files / Signals | NEW 2026-07-17 (agents-v2/v3 build Phase 6). A new agent_reader Postgres role (NOLOGIN NOINHERIT, mirrors consumer_authenticated's shape) now provides a GRANT-level (not just RLS-convention) read boundary for future agent-execution code: GRANT SELECT + 2 new dedicated RLS policies on files.document_chunk/document_index, plus GRANT EXECUTE on signals' 3 *_as_of() functions (see the Signals section above for that half of this seam). A new agentReaderDB() connection helper (packages/db/src/client.ts) mirrors consumerDB() exactly. A real gap was found and fixed during this build: the pre-existing authenticated-scoped RLS policies on document_chunk/document_index do NOT extend to agent_reader (Postgres role-scoped policies apply only to the exact role named, confirmed live via pg_policies) — GRANT SELECT alone would have left every agent_reader query silently returning zero rows; closed with 2 new SELECT-only policies. A new ESLint rule (apps/api/eslint.config.mjs, scoped to src/agents/**/*.ts) bans adminDb/tenantDB imports and raw set_config(...) calls from future agent-execution code. |
The read-side half of the boundary agent-execution code will need once AgentsService exists — closing the disclosed forward-ref Phase 4 (PROJECT_DECISIONS #65) left open. Honest disclosure: zero real call sites today (no AgentsService or other agent-execution read path exists) — logged as a new OPEN_ITEMS row, not treated as fully closed. files/signals are structurally unchanged (grant/policy-only). See footnote ²⁹ in MODULE_BUILD_STATUS.md and PROJECT_DECISIONS #67. |
| Platform | Agents | Every tenant-scoped table's tenant_id → platform.tenant.id (enforced FK). |
Tenant ownership, the standard scoping every module carries. |
Boundary note (GUARD) — what this section deliberately does NOT cover: agents' own internal cross-table architecture (saga-gate enforcement against real writes, the kill-switch history/state split, skill-activation authority composition, task-claim fencing, evidence reconstruction, the runaway-ceiling block) is intra-module machinery, not a cross-module seam, and is not restated here. The 6 named tables/columns above are the only points where Phase 5's own migration touched a table outside the agents schema; the 2 additional rows above (identity's skill-catalog seam and the agent_reader role) are Phase 6 (2026-07-17) additions to this same section, not part of Phase 5's own migration.
Search
| From | To | Mechanism | What flows |
|---|---|---|---|
| Search | Inventory | search_vector generated col on inventory.item + inventory.item_variant; queried via SearchService.search({module:'inventory', ...}) |
Product catalog search (item name/description/type) + variant search (SKU partial, fuzzy name). SearchService composes query; callers never query search_vector directly. |
| Search | CRM | search_vector generated col on crm.customer; queried via SearchService.search({module:'crm', ...}) |
Customer lookup by display_name, customer_number, company_name. Primary first step in two-step customer-name order/sale search. |
| Search | Purchasing | search_vector generated col on purchasing.vendor; queried via SearchService.search({module:'purchasing', ...}) |
Vendor search by name, code, legal_name. |
| Search | Orders | search_vector generated col on orders.order_header; queried via SearchService.search({module:'orders', ...}) |
Order lookup by order_number, po_number, job_reference. No customer-name snapshot — customer-name search requires CRM first step (see Composition Notes in SCHEMA.md § search). |
| Search | POS | search_vector generated col on pos.sale; queried via SearchService.search({module:'pos', ...}) |
Sale lookup by sale_number, po_number, job_reference. No customer-name snapshot — same two-step compose required. |
| All modules | Search | None — Search adds touches to source tables; source modules do NOT call SearchService. SearchService is a read-only query layer over source-table GIN indexes. |
Search is a pure read-layer; it introduces no writes to source tables beyond the DB-maintained generated columns. Tenant isolation: every SearchService query MUST include WHERE tenant_id = ?; GIN indexes carry no tenant partitioning. |
Reporting
| From | To | Mechanism | What flows |
|---|---|---|---|
| Reporting | POS / Orders / CRM / Inventory / Purchasing / Billing / Payments / Admin / Integrations / Notifications / Audit / Files / AI / Platform / Multi-loc | ReportingService reads via service_role (pooled port 6543, bypasses RLS intentionally — cross-schema reads cannot use tenant RLS policies). Read-only matview sources and live queries. No FK enforcement on these read paths. |
Source data for materialized views and live report queries. |
| Source modules | Reporting | Source modules emit *_changed events per Rule 6. ReportingService subscribes and refreshes affected matviews. Each MV declares its strategy (aggressive / staleness_window) and triggering event — see SCHEMA.md § reporting § Materialized View Specifications. |
MV invalidation signals. Source modules do NOT call ReportingService for writes — Reporting is a pure consumer. |
| Reporting | billing.ar_statement |
ReportingService reads billing.ar_statement at period-close time to populate period_close_snapshot.ar_outstanding_cents. |
AR outstanding summary figure. READS, does not shadow — Billing owns AR period snapshots; Reporting adds the cross-module summary. |
| Nothing | Reporting | Terminal — no module reads from reporting.* as a dependency. No FKs point INTO reporting.*. Zero forward-refs out. |
— |
MV → event subscriptions (full Rule-6 declarations — see SCHEMA.md § reporting):
| MV | Triggering event | Strategy |
|---|---|---|
mv_sales_summary |
sales_changed |
Aggressive |
mv_sales_by_customer |
sales_changed |
Staleness window 30s |
mv_inventory_valuation_current |
stock_changed |
Aggressive |
mv_gross_margin |
sales_changed |
Staleness window 30s |
mv_customer_rfm |
sales_changed (batch) |
Daily staleness |
mv_ar_aging |
ar_changed |
Staleness window 30s |
mv_ap_aging |
ap_changed |
Staleness window 30s |
mv_vendor_performance |
receipt_changed (batch) |
Daily staleness |
mv_sync_health |
sync_changed |
Staleness window 30s |
mv_notification_summary |
notification_changed |
Staleness window 30s |
Consumer
consumer is module #23 (the Consumer Layer's first real v2 build), schema-locked 2026-07-11 — 10 tables/104 cols, superseding the stale v1-carryover placeholder (4/47) every row below previously described — see PROJECT_DECISIONS #56. This entry replaces the prior "STALE-DOCUMENTATION CORRECTION" note that lived here since the crm lock (2026-07-06, DR-34) — the consumer schema now genuinely exists and every seam below is verified live, not carried-over v1/pre-pivot intent. Schema-only so far — no ConsumerService yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| CRM | Consumer | crm.customer.consumer_id remains a DEFERRED forward-ref (plain nullable UUID, no FK — confirmed still true at this build; crm was not reopened) — even though consumer.consumer now exists, closing this FK was out of this build's own scope. The reverse direction is real: consumer.consumer_merchant_link.crm_customer_id is a deliberate LOOSE ref (no FK either — cross-schema, different RLS domain: consumer-scoped vs. tenant-scoped), matching v1's own precedent. |
The Model B bridge stays two one-directional, unenforced pointers — crm.customer optionally names a consumer; consumer.consumer_merchant_link optionally names a crm customer — reconciled at the service layer, not the DB. |
| Platform | Consumer | consumer.consumer_merchant_link.tenant_id/consumer.event.tenant_id → platform.tenant.id (enforced FKs — the only 2 of consumer's 10 tables that carry a tenant_id at all). |
Tenant ownership for the 2 deliberately tenant-scoped exceptions within this otherwise non-tenant schema. |
| Multi-Location | Consumer | consumer.event.site_id → multi_loc.site(id, tenant_id) (composite FK, nullable — an anonymous web event may carry no site). |
Which physical location (if any) a merchant-recorded engagement event happened at. |
| Shared | Consumer | consumer.consumer_address.country_code → shared.country.iso_alpha2 (enforced FK). |
Address country validation, same precedent as multi_loc/crm/inventory/pricing/pos. |
| Rewards | Consumer | rewards.loyalty_account.consumer_id → consumer.consumer.id (enforced FK, plain not composite — consumer.consumer is non-tenant-scoped, so no composite target exists). consumer_id is a dimension on the tenant-scoped loyalty_account row, NOT the RLS scope. |
Which consumer holds a given tenant's loyalty account. Real seam, confirmed live at this build — see the Rewards section below. |
| Offers | Consumer | offers.offer.consumer_id (nullable, targeted-offer dimension) / offers.offer_code.consumer_id (nullable) / offers.offer_assignment.consumer_id (NOT NULL) / offers.offer_redemption.consumer_id (NOT NULL) / offers.customer_discount_exposure.consumer_id (NOT NULL) — all → consumer.consumer.id, enforced plain FKs, confirmed live. |
Consumer identity foundation for offer targeting, assignment lifecycle, redemption, and per-consumer discount-exposure tracking — see the Offers section below. |
| Consumer (internal) | Consumer | consumer.get_cross_tenant_activity(p_consumer_id uuid) — a SECURITY DEFINER function (owned by a role with real table access, not superuser), taking the consumer id as a parameter, never an ambient session GUC. Unions a consumer's rewards.loyalty_account balances and offers.offer_assignment rows (status issued/viewed/claimed) across every tenant they're linked to. |
The one sanctioned cross-tenant READ path for the consumer app's "my rewards / my offers across every nursery" view — enforced at the service layer (a future ConsumerGuard), not by the function itself, disclosed exactly like ai.ai_request.tenant_id/agent_identity_id consistency being service-layer-enforced elsewhere in this codebase. |
| consumer_app | Consumer | (forward — not yet designed) Will consume consumer.consumer + consumer_merchant_link as the identity foundation. |
Consumer Layer phase (a later phase, direction only). |
The consumer_authenticated boundary (GUARD, replaces the prior "ConsumerService is the only entry point" framing — now DB-enforced, not just a service-layer convention):
- 8 of
consumer's 10 tables (consumer,consumer_identifier,consumer_address,consumer_interest,consumer_consent,identity_merge_event,identity_map,consumer_feature) are consumer-scoped RLS and structurally unreachable by the merchantauthenticatedrole —REVOKE ALLat the ACL layer, not merely an absent grant, confirmed live viapg_catalog. - These 8 tables are reachable only through the new
consumer_authenticatedPostgres role (NOLOGIN NOINHERIT), driven by aconsumerDB()connection helper mirroringtenantDB()'s ownSET LOCAL ROLE/set_config()pattern — not a Supabase Custom Access Token Auth Hook (investigated and found structurally viable in this Supabase project in general, but this application'sapps/apinever routes through PostgREST's own auto-role-switching layer at all, so the hook mechanism doesn't apply here). - The 2 exceptions (
consumer_merchant_link,event) stay merchant-authenticated-accessible with standard tenant-scoped RLS —consumer_authenticatedhas zero grant on either. - A consumer's cross-tenant activity is visible ONLY through
consumer.get_cross_tenant_activity()above — never a direct cross-tenant JOIN from any request path.
Rewards
rewards is module #24 (the Consumer Layer's second real v2 build), schema-locked 2026-07-11 — 6 tables/102 cols, superseding the stale v1-carryover placeholder (6/89) — see PROJECT_DECISIONS #56. Replaces the prior staleness-flagged section — every seam below is verified live. Schema-only so far — no RewardsService yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Rewards | POS | rewards.loyalty_point_ledger.sale_id → pos.sale(id, tenant_id) (composite FK, nullable — real, confirmed live, riding a new pos.sale UNIQUE(id, tenant_id) prerequisite this build added). THE SEAM: a sale-triggered entry_type='earn' ledger row references the triggering sale; RewardsService/POSService integration (not yet built) will call RewardsService.earn()/.redeem() at sale time. sale_id is required when source_type='sale' (CHECK-enforced), NULL for manual/expiry entries. |
Sale-triggered points earn, reference-don't-copy — the real, DB-enforced version of v1's own points_ledger.sale_id intent. |
| Rewards | Consumer | rewards.loyalty_account.consumer_id → consumer.consumer.id (enforced plain FK, confirmed live). rewards.* is fully tenant-scoped; consumer_id is a dimension only, never the RLS scope. |
Which consumer holds this tenant's loyalty account — see the Consumer section above. |
| Rewards | Platform | rewards.*.tenant_id → platform.tenant.id (enforced FKs across all 6 tables — the RLS scope on every table in this schema). |
Tenant ownership of the loyalty program, accrual rules, reward tiers, redemption catalog, accounts, and ledger. |
| Identity | Rewards | Every *_actor_id column across all 6 tables → identity.actor.id (enforced FKs, confirmed live) — loyalty_point_ledger.created_by_actor_id is the manual-adjustment audit trail (entry_type='adjust' requires a non-NULL note, chk_loyalty_point_ledger_adjust_requires_note). |
Actor attribution for the full autonomy-first pattern (PROJECT_DECISIONS #19), consumed here for the first time by Rewards. No new authority mechanism — pure consumer of identity.agent_duty_grant. |
| Rewards (internal) | Rewards | rewards.sync_loyalty_account_balance() — a single atomic BEFORE INSERT trigger on loyalty_point_ledger whose own UPDATE ... RETURNING balance_after_points takes a row lock on the parent loyalty_account, serializing concurrent redemptions (closes the naive-shape overshoot bug — see PROJECT_DECISIONS #56 for the full A/B live-reproduction). chk_loyalty_point_ledger_balance_after_points_nonneg is the independent CHECK-layer backstop. |
Correct, race-free point balance maintenance — no service-layer read-then-write can bypass it. |
| ConsumerService (deferred) | Rewards | Once built, will read rewards.loyalty_account cross-tenant via consumer.get_cross_tenant_activity() for the consumer app's "my rewards" view (see the Consumer section above) — service-layer only, not a direct query path. |
Per-tenant balances aggregated for a cross-merchant display. No pooling across nurseries — per-business balances only. |
reward_option vs offers boundary (GUARD, unchanged from v1's own precedent):
reward_option= points-funded redemption catalog (nursery-funded; points balance is the currency). Never put a promo code, coupon, or Vrida-funded discount here.offersmodule = coupon/promo issuance, including Vrida-funded offers with settlement economics. Never put a points-exchange redemption there.- If a feature seems to blend the two, it is a service-layer integration — not a schema merge.
Offers
offers is module #25 (the Consumer Layer's third real v2 build and this build's own AI-authored-offer surface), schema-locked 2026-07-11 — 6 tables/115 cols, superseding the stale v1-carryover placeholder (4/68) — see PROJECT_DECISIONS #56. Replaces the prior staleness-flagged section — every seam below is verified live. Schema-only so far — no OffersService yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Offers | POS | offers.offer_redemption.sale_id → pos.sale(id, tenant_id) (composite FK, NOT NULL — real, confirmed live, v1's own "THE REDEMPTION SEAM," riding the same new pos.sale UNIQUE(id, tenant_id) prerequisite Rewards' own seam uses) and .sale_line_id → pos.sale_line(id, tenant_id) (composite, nullable — the margin-floor verification target, new this build). pos.sale/pos.sale_line carry NO offer seam columns — no POS-side additive touch, same reference-don't-copy pattern as rewards.loyalty_point_ledger.sale_id. |
Redemption event ties an offer to the triggering sale (and, when known, the specific sale line whose margin the discount was checked against). |
| Offers | Consumer | offers.offer.consumer_id (nullable — targeted-offer dimension) / .offer_code.consumer_id (nullable) / .offer_assignment.consumer_id (NOT NULL) / .offer_redemption.consumer_id (NOT NULL) / .customer_discount_exposure.consumer_id (NOT NULL) → consumer.consumer.id (enforced plain FKs, confirmed live). |
Consumer identity foundation for offer targeting, assignment lifecycle, redemption tracking, and per-consumer/per-period discount-exposure accounting. |
| Offers | Platform | offers.*.tenant_id → platform.tenant.id (enforced FKs across all 6 tables — the RLS scope on every table in this schema). |
Tenant ownership of the offer catalog, code pool, assignment lifecycle, redemption ledger, targeting rules, and discount-exposure tracking. |
| Offers | CRM | offers.offer_targeting_rule.segment_definition_id → crm.customer_segment_definition.id — a plain FK, not composite (cross-tenant integrity is instead DB-enforced by a dedicated trigger, offers.validate_offer_targeting_rule_segment(), mirroring pricing.trg_price_rule_validate_supersession's own precedent exactly — allows a NULL-tenant/global segment or one belonging to the same tenant, rejects any other tenant's). |
Segment-, category-, engagement-, geography-, or visit-frequency-based offer targeting, reusing CRM's existing segment catalog rather than duplicating it. |
| Offers (internal) | — | RETIRED, 2026-07-18 (PROJECT_DECISIONS #70) — this FK no longer exists. offer_targeting_rule.growing_zone_code, a plain FK into climate_zone.code (the global hardiness-zone reference table, then owned by shared), and the rule_type='growing_zone' value are GONE: the column was renamed attribute_ref and its FK dropped entirely, rule_type='growing_zone' renamed 'attribute_match' — the generic offer engine must not structurally depend on any vertical's reference data (SCHEMA_CONVENTIONS.md §21). attribute_ref is now a generic loose reference with NO FK at all — a nursery tenant happens to populate it with a nursery_ref.climate_zone code, but offers has zero schema-level awareness of nursery_ref's existence. |
Historical pointer only — no live FK replaces this; attribute_ref is intentionally unenforced (populated, not validated, by whichever vertical needs it). |
| Identity | Offers | Every *_actor_id column across all 6 tables → identity.actor.id (enforced FKs, confirmed live) — including offer_targeting_rule's own created_by_actor_id/reviewed_by_actor_id, added this build alongside the standard automation_source/review_status/review_reason/reviewed_at autonomy pack. |
Actor attribution for the full autonomy-first pattern (PROJECT_DECISIONS #19). No new authority mechanism — pure consumer of identity.agent_duty_grant. |
| Offers (internal) | Offers | offers.check_and_sync_offer_budget() — a single atomic BEFORE INSERT trigger on offer_redemption merging 3 concerns: (a) margin-floor check, failing CLOSED (not silently skipped) against a zero/NULL avg_cost_cents; (b) max_discount_percent/max_discount_amount_per_order guardrail check; (c) the budget-cap check + offer.budget_used_cents/offer_code.redeemed_count sync, all inside the same row-locking UPDATE — closes the same concurrency-race class Rewards' own sync_loyalty_account_balance() closes (see PROJECT_DECISIONS #56 for the live A/B reproduction). chk_offer_ai_requires_guardrail is the independent CHECK-layer AI-margin-bypass backstop: any offer with provenance != 'human_defined' structurally requires a non-NULL max_discount_percent or max_discount_amount_per_order at INSERT time — an ungoverned AI-authored offer cannot exist in the table at all. |
Correct, race-free offer-budget and redemption-count maintenance, with a fail-closed AI-authorship guardrail baked into the schema itself, not left to service-layer discipline. |
| ConsumerService (deferred) | Offers | Once built, will read offers.offer_assignment cross-tenant via consumer.get_cross_tenant_activity() for the consumer app's "my offers" view (see the Consumer section above) — service-layer only, not a direct query path. |
Per-tenant available offers aggregated for a cross-merchant display. |
| Offers | Platform (deferred) | When funding_source = 'vrida', OffersService (not yet built) will feed redemption facts to a future platform settlement concept. Merchant-funded only is live; Vrida-funded stays a disclosed, unenforced value in the chk_offer_funding_source CHECK — no settlement ledger exists yet. |
Vrida→merchant reimbursement data feed (not yet built). |
Settlement boundary (GUARD, unchanged from v1's own precedent):
offersrecords offer definitions,funding_source, and redemption events. It does NOT own Vrida→merchant credit settlement.- When
funding_source = 'vrida', the financial settlement (Vrida crediting the nursery) is platform's domain — platform owns the Vrida↔tenant financial relationship. - Do NOT add settlement/credit/invoice/funding-ledger tables to the
offersschema. Offers feeds settlement; platform builds it.
Offers vs rewards boundary (GUARD — see Rewards section above for the full GUARD block):
offers= coupon/promo issuance (discrete issued instruments,offer/offer_code/offer_assignment/offer_redemption).reward_option= points-funded redemption catalog.- Never merge.
OffersServiceandRewardsServiceare parallel, not-yet-built services — a checkout applying both would call each independently,offer.stacking_policy(exclusive/stackable/best_price) governing combination of multiple offers in one checkout.
Returns
returns is module #27 (the customer RMA — return merchandise authorization — module), schema-locked 2026-07-11, built as Pass 2 of a 2-pass session immediately after Pass 1 fixed rewards'/offers' own reversal triggers to be proportional (see the Rewards/Offers sections above and PROJECT_DECISIONS #60) — see PROJECT_DECISIONS #61. 9 tables / 153 cols: return_authorization (29, the RMA header, FULL autonomy pack), return_authorization_line (28, freezes discount/tax allocation at RA-creation time), return_source_line_tracker (10, NEW — an aggregate-cap cache mirroring offers.offer_redemption_reversal_tracker's own shape, since pos.sale_line/orders.order_line are append-only and off-limits for a counter column), return_resolution (21, the outcome header — refund/store_credit/replacement/repair/warranty_credit/reject), return_resolution_line (8, append-only, mirrors billing.ar_charge_line/purchasing.vendor_credit_line), return_receipt (14, mirrors receiving.goods_receipt), return_receipt_line (16, mutable, mirrors receiving.goods_receipt_line), return_reason (10, tenant-scoped catalog), warranty (16, revived v1 pos.guarantee). Returns introduces NO new authority mechanism anywhere in this seam catalog — every cross-module write reuses an existing owning-module mechanism (rewards/offers' own now-proportional reversal math, inventory's derive-and-cap posting pattern, tax's existing reversal contract) rather than reimplementing it. Schema-only so far — no ReturnsService yet.
| From | To | Mechanism | What flows |
|---|---|---|---|
| Returns | POS | Read seam: return_authorization.source_sale_id → pos.sale(id, tenant_id) / return_authorization_line.sale_line_id → pos.sale_line(id, tenant_id) (composite FKs, nullable, confirmed live — riding the UNIQUE(id, tenant_id) prerequisite this same migration added to both pos.sale_refund/sale_refund_line). Execution seam: return_resolution.pos_sale_refund_id → pos.sale_refund(id, tenant_id) (composite, required when resolution_type='refund' via CHECK) and .replacement_sale_id → pos.sale(id, tenant_id) (composite, for the replacement-resolution path). Returns reads the sale/sale-line it's returning against and, on refund, references the pos.sale_refund row an independent POS-side flow creates — returns never writes refund money itself and never duplicates it; pos.sale_refund stays the one source of truth for refunded amounts, mirroring the codebase's existing reference-don't-copy pattern (e.g. orders.order_header.fulfilled_sale_id). |
Which sale/sale-line an RMA traces back to (read), and which POS refund executed the money movement (reference, not re-implementation). |
| Returns | Orders | Read seam, same pattern as POS: return_authorization.source_order_id → orders.order_header(id, tenant_id) / return_authorization_line.order_line_id → orders.order_line(id, tenant_id) (composite FKs, nullable, confirmed live — riding the UNIQUE(id, tenant_id) prerequisite this same migration added to both orders.order_header/order_line). |
Which order/order-line an RMA traces back to, for orders never fulfilled through POS. Mutually exclusive in practice with the POS source (an RMA traces to one origin), but not a live CHECK — the FKs are independently nullable. |
| Returns | Inventory | Write seam (posting, never a direct inventory.stock write): return_receipt_line.stock_movement_id/.stock_movement_line_id → inventory.stock_movement/.stock_movement_line (composite, nullable). returns.post_and_cap_return_receipt_line() (AFTER INSERT on return_receipt_line) derives the absorbable quantity itself via SELECT ... FOR UPDATE on the target RA line, then posts the stock_movement/stock_movement_line pair atomically in the same invocation using the derived (capped) quantity, never the raw caller-supplied value — the same derive-and-cap pattern receiving.post_and_cap_goods_receipt_line()'s own corrected (2nd-round) shape uses. Tolerance policy is BLOCK, not flag — a deliberate, disclosed deviation from Receiving's own "flag" default, since a customer physically returning goods twice against the same authorization is a materially more dangerous default to leave un-gated. inventory.stock_movement.source_module CHECK was widened this same migration to accept 'returns'. Returns never writes inventory.stock directly — the same guard every other stock-posting module in this codebase (Receiving, POS) already carries. |
Returned goods become an inventory movement (movement_type='returned'), capped and idempotent, never a direct stock-row mutation. |
| Returns | Rewards | return_resolution.loyalty_reversal_ledger_id → rewards.loyalty_point_ledger(id, tenant_id) (composite, nullable). Returns writes a 'reverse'-typed ledger row using Rewards' OWN mechanism (rewards.sync_loyalty_account_balance(), made proportional at Pass 1, PROJECT_DECISIONS #60) — not a new authority; Returns never re-implements point-balance math, exactly as it never re-implements refund math. A partial return proportionally claws back points (e.g. returning 2 of 5 units claws back 2/5 of the points earned), cumulative-capped against the original earn via Pass 1's own tracker table. |
A return proportionally reverses the loyalty points a sale earned, via Rewards' own now-proportional reversal mechanism — Returns only supplies the reference. |
| Returns | Offers | return_resolution.offer_reversal_redemption_id → offers.offer_redemption(id, tenant_id) (composite, nullable). Same pattern as Rewards above: Returns writes a 'reverse'-typed redemption row using Offers' OWN mechanism (offers.check_and_sync_offer_budget(), made proportional at Pass 1) — not a new authority. A partial return proportionally releases offer budget (e.g. returning 2 of 5 units releases 2/5 of the discount budget consumed), cumulative-capped against the original redemption. |
A return proportionally releases the offer/discount budget a sale consumed, via Offers' own now-proportional reversal mechanism — Returns only supplies the reference. |
| Returns | Tax | No schema seam needed — Returns is the first real caller of an existing contract, not a new one. tax.tax_calculation's own reversal contract (reversed_calculation_id self-FK + calculation_type IN ('original','reversal') + sign-aware CHECKs, built at Tax's own lock and Remediation Phase 3, PROJECT_DECISIONS #31/#39) already fully supports a refund's tax reversal netting to zero. Returns triggers that existing POS/Orders-side refund-tax-capture path (pos.sale_refund_line.tax_amount_cents/.tax_rate, pos.sale_refund.tax_refunded_amount_cents) indirectly via its own pos.sale_refund reference above — no new column, table, or FK was added to either returns or tax for this. |
Refund tax reversal nets to zero at remittance-report time, entirely through Tax's own pre-existing mechanism — Returns adds no new tax-schema surface at all. |
| Returns | CRM | return_authorization.customer_id → crm.customer.id (bare FK, matching the universal crm.customer reference convention used codebase-wide, e.g. pos.sale.customer_id/orders.order_header.customer_id) — nullable, read-only. An unreferenced/anonymous walk-in return is fully supported (no customer_id at all), consistent with this codebase's own Phase 4 ALLOW decision (PROJECT_DECISIONS #40). |
Which customer an RMA is associated with, when known — Returns never writes to crm.customer. |
| Returns | Files | files.attachment.entity_type CHECK widened this same migration to accept 'return_authorization' — the polymorphic many-to-many join (files.attachment, PROJECT_DECISIONS #58/#59) can now attach a file (e.g. a damage photo, a signed RMA form) to a return_authorization row. No column added to returns itself — the seam is entirely on Files' side, mirroring how files.attachment already covers other entity types without a matching returns-side column. Separately, warranty.signature_ref is a deferred plain-column forward-ref to files.file.id (no FK yet), folded into the existing Files FK-wiring bundle (see OPEN_ITEMS) alongside 8 other modules' own forward-refs — not a new gap this build introduces. |
A return authorization can carry attached evidence files once FilesService exists; a warranty claim's signature capture is deferred to the same not-yet-executed Files FK-wiring bundle every other pre-Files-lock forward-ref column is waiting on. |
| Returns | Approvals | approvals.approval_request.source_module CHECK widened this same migration to accept 'returns' — a return authorization or resolution can request tenant-side approval routing (e.g. a high-value or high-risk-score RMA) via the existing polymorphic approval_request.source_ref seam (no FK, validated by the CHECK only — same pattern every other module's approval-routing seam uses). No column added to returns itself. |
An RMA can be routed through the generic approval-workflow engine once ReturnsService and ApprovalsService both exist — the schema-level door is open, nothing calls through it yet. |
| Identity | Returns | Every *_actor_id column across all 9 tables (created_by_actor_id, reviewed_by_actor_id, received_by_actor_id, voided_by_actor_id, etc.) → identity.actor.id (enforced FKs, confirmed live). Returns introduces no new authority mechanism — it is a pure consumer of identity.agent_duty_grant, verbatim, same as every other product module (crm, inventory, pos, orders, purchasing, tax, billing, payments, admin, receiving). Autonomous returns actions (draft an RMA, propose a resolution) would check for an active grant scoped to module_code='returns' permission codes, once ReturnsService exists. |
Actor attribution for the full autonomy-first pattern (PROJECT_DECISIONS #19), consumed here for the first time by Returns — no new column or mechanism added to identity itself. |
5 companion reopens bundled into this same migration (all constraint/CHECK-shape only, ZERO column/table count impact on any of these 5 modules): pos.sale_refund/sale_refund_line each gained UNIQUE(id, tenant_id); orders.order_header/order_line each gained UNIQUE(id, tenant_id); inventory.stock_movement.source_module, approvals.approval_request.source_module, and files.attachment.entity_type CHECKs were each widened to accept a returns-related value ('returns', 'returns', 'return_authorization' respectively). See the individual rows above and PROJECT_DECISIONS #61 for the full live-reproduction record. pos, orders, inventory, approvals, and files are all re-locked by this build (constraint-widen only) — each of their own sections above now also carries its own "Returns |
returns boundary note (GUARD) — what returns explicitly does NOT own:
- Refund money movement — belongs to POS (
pos.sale_refund) exclusively. Returns references the resulting refund row; it never computes or writes a refunded amount itself. - Loyalty-point / offer-budget reversal math — belongs to Rewards/Offers exclusively. Returns writes the triggering
'reverse'-typed row; the balance/budget arithmetic lives entirely in each module's own trigger. - Tax reversal — belongs to Tax exclusively, via the pre-existing reversal contract Returns is simply the first real caller of.
inventory.stockmutation — belongs to Inventory exclusively. Returns only ever writesinventory.stock_movement/stock_movement_linerows, the same guard every other stock-posting module in this codebase carries.- Store-credit balance tracking — RESOLVED 2026-07-18 (Phase 3 stored-value build, PROJECT_DECISIONS #71).
billing.store_credit_account/store_credit_transactionnow exist;return_resolution.store_credit_transaction_idis a real composite FK to the'issue'-typed ledger entry (required forstore_credit/warranty_creditresolutions via CHECK). The oldstore_credit_referencefree-text column is deprecated in place.
Phase 3 — Gift Card + Store Credit seams (2026-07-18, PROJECT_DECISIONS #71)
New real composite FKs added by the stored-value build, replacing 2 formerly-unenforced forward-refs and adding 5 more:
| Column | Target | Notes |
|---|---|---|
pos.sale_payment.gift_card_id |
billing.gift_card (id, tenant_id) |
REAL, replaces the old unenforced forward-ref. The fail-closed tender gate (chk_sale_payment_no_unbacked_tender_type) narrowed to payment_method != 'reward' in the same migration. |
pos.sale_payment.store_credit_id |
billing.store_credit_account (id, tenant_id) |
REAL, replaces the old unenforced forward-ref. |
billing.gift_card.customer_id |
crm.customer (id, tenant_id) |
Nullable — anonymous bearer cards are legitimate. |
billing.gift_card.issued_sale_id |
pos.sale (id, tenant_id) |
Nullable — NULL for bulk/promotional issuance; required when source='purchase'. |
billing.store_credit_account.customer_id |
crm.customer (id, tenant_id) |
NOT NULL — the never-merge GUARD's structural half (vs. gift_card's bearer model). |
billing.gift_card_transaction/.store_credit_transaction.sale_payment_id |
pos.sale_payment (id, tenant_id) |
Nullable, required only for 'redeem' entries — one redemption per payment ever (partial unique). |
billing.gift_card_transaction/.store_credit_transaction.sale_refund_id |
pos.sale_refund (id, tenant_id) |
Nullable — refund_to_instrument/clawback context (store credit's 'issue' may also carry it, issued from a refund). |
returns.return_resolution.store_credit_transaction_id |
billing.store_credit_transaction (id, tenant_id) |
NEW — replaces the loose store_credit_reference text column; required for store_credit/warranty_credit resolutions. |
Placement note (GUARD): stored-value instruments live in billing, not pos — a deliberate reversal of v1's own placement, since a gift card/store credit balance is a LIABILITY on the tenant's books (billing's charter), not a register concern. pos only tenders against the instrument; billing owns the ledger.
'reward' stays fail-closed — rewards (locked since #56/#60) does not back a reward TENDER: no pos→rewards linkage column exists anywhere, no points→money bridge exists, and rewards.loyalty_point_ledger's own chk_loyalty_point_ledger_redeem_requires_reward_option structurally forces every redemption through a pre-configured reward_option (a price-adjustment shape, not a tender shape). See OPEN_ITEMS for the precise unlock trigger.
Audit
| From | To | Mechanism | What flows |
|---|---|---|---|
| All modules | Audit | AuditService.log(source_table, source_ref, ...) |
Modules emit audit events via AuditService. Reference-don't-copy: audit.audit_log points at source rows via source_table / source_ref — it does NOT copy row data. |
Identity
| From | To | Mechanism | What flows |
|---|---|---|---|
| Supabase Auth | Identity | identity_user.supabase_auth_user_id (UUID reference, not enforced FK) |
Supabase Auth owns authN (login, sessions, MFA); Identity owns authZ (roles, permissions, tenant membership). |
| Identity | Shared | identity.agent_duty_grant.spend_limit_currency_code → shared.currency.iso_code (enforced FK, NEW dependency — introduced by the 2026-07-06 agent_duty_grant build, PROJECT_DECISIONS #22) |
Validates the currency of an agent's per-action spend ceiling against the shared ISO 4217 catalog. |
| Identity | Multi-Location | identity.agent_duty_grant.scope_id → multi_loc.site.id (enforced FK, NEW dependency — introduced by the 2026-07-06 agent_duty_grant build, PROJECT_DECISIONS #22). Real, not deferred — unlike user_permission_override.scope_id's deferred FK (predates multi_loc being locked), this column was created after multi_loc locked, so no deferral was needed. |
Scopes an agent's duty grant to a specific site when scope_type='site'. |
| Identity | (all modules consuming agents) | The agent kill-switch (Remediation Phase 3, Item 12, 2026-07-08, PROJECT_DECISIONS #39): identity.agent_identity.status (active/suspended/killed) + suspended_at/suspended_by_actor_id/suspension_reason. This is ONE LINK in a precedence chain of independently-owned half-mechanisms that already existed: tenant status (an entire tenant can be off) > platform.ai_credit_account status (a tenant's AI budget can be exhausted/suspended — platform-owned; ai.ai_credit_account does NOT exist, a citation this entry corrects) > this agent's own status (NEW) > agent_duty_grant (per-permission authority, revocable/expirable) > agents.agent_skill_assignment (what the agent may attempt — RETARGETED 2026-07-17, Phase 6: the legacy identity.agent_skill_assignment this link originally named was DROPPED and superseded by agents.skill_definition/skill_version/agent_skill_assignment, see the Agents section above) > role assignment > feature flags. 'killed' is meant to be treated as terminal by the service layer — NOT DB-enforced irreversible (no trigger blocks a killed→active transition; that judgment is left to IdentityService, not baked into schema). |
No single mechanism was authoritative before this column; it is the missing top-of-agent link in an existing chain, still evaluated in order with the rest — every consuming module (crm/inventory/pos/orders/purchasing/tax/billing/payments/admin, all pure consumers of agent_duty_grant) is affected by this precedence chain. |
Shared
shared is 12 tables / 136 columns as of Remediation Phase 4 (up from 10/108, see PROJECT_DECISIONS #40 — of that +28 col delta, +16 is Phase 4's own 2 new tables and +12 is a disclosed drive-by correction of a pre-existing, Phase-4-unrelated stale baseline that never reflected entry #21's own FIX 1 review-seam addition to plant/plant_common_name/plant_climate_zone).
| From | To | Mechanism | What flows |
|---|---|---|---|
| Shared | Identity | RETIRED, 2026-07-18 (PROJECT_DECISIONS #70) — the tables this row described no longer exist in shared. plant/plant_common_name/plant_climate_zone moved to nursery_ref via ALTER TABLE ... SET SCHEMA (Phase 2, Nursery Vertical Extraction). See the new Nursery Reference section above for the current, equivalent seam (nursery_ref.plant/plant_common_name/plant_climate_zone.created_by_actor_id → identity.actor). |
Historical pointer only — see the Nursery Reference section above for the live seam. |
| Shared (internal) | Shared | shared.exchange_rate (Remediation Phase 4 Item 16, PROJECT_DECISIONS #40, uuid surrogate PK + UNIQUE(from_currency_code, to_currency_code, effective_date) — deliberately not a natural composite PK, matching this codebase's universal single-column-PK convention over a shared-module-native shape) + shared.payment_terms_catalog (Item 17b, 10 seeded rows) — both pure global reference data, no tenant_id, no RLS, zero exception to this schema's 100%-global convention. |
exchange_rate has no consumer FK yet; billing.ar_payment_application's new currency-agreement trigger (see the Billing section above) is the real enforcement mechanism this item enables. payment_terms_catalog is consumed by crm.customer.payment_terms_id, purchasing.vendor.payment_terms_id, purchasing.purchase_order.payment_terms_id (see those sections above). |
Multi-Location
| From | To | Mechanism | What flows |
|---|---|---|---|
| Multi-Location | Identity | multi_loc.site.created_by_actor_id/updated_by_actor_id/reviewed_by_actor_id → identity.actor (nullable FKs, NEW dependency alongside multi_loc's existing platform/shared deps; introduced by the 2026-07-06 autonomy-first backfill, see docs/decisions/PROJECT_DECISIONS.md #19) |
Actor attribution + review-seam attribution for site records. |
Platform
platform is on its 5th reopen (schema-locked 2026-06-09; reopened 2026-06-30 for announcement/platform_setting, 21→23 tables; reopened 2026-07-06 for the autonomy-first backfill, PROJECT_DECISIONS #19; reopened 2026-07-07 for the identity absorption below, PROJECT_DECISIONS #35; reopened again 2026-07-08 for Remediation Phase 4, PROJECT_DECISIONS #40). 23→26 tables / 403→408→440 columns as of this reopen.
Remediation Phase 4 (2026-07-08, PROJECT_DECISIONS #40) additions: accounting_period (9 cols — the first use of EXCLUDE USING gist in this codebase, preventing two overlapping fiscal periods per tenant; a FLAG-NOT-REJECT trigger, flag_closed_period_business_date(), fires on pos.sale/pos.sale_refund/pos.register_cash_entry when a row's business_date falls within a closed period — deliberately non-blocking since offline-sync needs a late-arriving sale to still land); legal_entity (8 cols — 1:N from tenant, backfilled 1 primary row per tenant at build time, a partial-unique index enforcing exactly one primary per tenant; nullable entity_id added to 10 header tables across the codebase — see the Admin/Tax/Billing/Purchasing/Orders/POS sections above and the platform.contract/platform.billing_account rows below); outbox (13 cols — a durable transactional-outbox event table, genuinely mutable, gen_random_uuid() PK not uuid_generate_v7(), no consumer/dispatcher service yet).
Platform is the single source of truth for tenant IDENTITY — governing rule, PROJECT_DECISIONS #34/#35. platform.tenant_profile (39 cols as of this reopen, up from 34) owns every identity-shaped fact about a tenant: legal_name, business_type, phone, legal_address, mailing_address, dbas (JSONB array of trade names — retyped from the old scalar trading_name), business_classification_code (NAICS), ein_ref (vault reference). Admin — now built (2026-07-07, PROJECT_DECISIONS #36) — references platform.tenant for identity and never duplicates it, confirmed live: zero identity-shaped columns exist across Admin's 10 tables. This is a service-layer read (PlatformService), not a cross-schema FK — this codebase's convention is that tenant identity is never looked up cross-schema by FK, only through the owning module's service class (Rule 1). Admin owns only the tenant's TECHNICAL/OPERATIONAL configuration (api_key, integration_config, webhook_config, hardware_device, tenant_setting) plus presentational surface (tenant_branding, compliance_document) — never identity; the tenant-side approval engine, formerly listed here, MOVED OUT to its own approvals module 2026-07-09 (PROJECT_DECISIONS #44, reversing PROJECT_DECISIONS #34 Section 5's Option B decision). This reverses v1's own "Admin Module Scope" decision, which had assigned identity fields (legal name, business type, EIN, addresses, DBAs, branding) to Admin's tenant_business_profile table; that table's full 17-column fate mapping (which platform column each field absorbs into) is recorded verbatim in PROJECT_DECISIONS #34 — see that entry, not restated here. tax_id and logo_url on platform.tenant_profile are DEPRECATED IN PLACE (column-comment only, no DDL change) — superseded by ein_ref (vault reference) and admin.tenant_branding.logo_ref (Admin v2 now exists) respectively; both stay readable/writable until their own later, separately-flagged drop migrations (see OPEN_ITEMS). See the dedicated Admin section above (seam catalog) for Admin's full outbound seam list; the tenant-side approval engine's forward-compatibility note has moved with it to the dedicated Approvals section (the engine is no longer part of Admin).
| From | To | Mechanism | What flows |
|---|---|---|---|
| All modules | Platform | Every tenant-scoped table carries tenant_id → platform.tenant (enforced FK) |
Tenant identity root — platform migrates first; every other module's FK depends on it |
| Admin | Platform | Service-layer read of PlatformService (e.g. getTenantProfile(tenantId)) — no FK, by convention (tenant identity is never looked up cross-schema by FK in this codebase). Confirmed live at Admin's 2026-07-07 build. |
Admin reads tenant legal name, business type, addresses, DBAs, NAICS, EIN-vault-ref from Platform at render/config time; Admin never stores its own copy. Standing ownership boundary — see the governing-rule note above (PROJECT_DECISIONS #34/#35), and the dedicated Admin section above for the same seam from Admin's side. |
| All modules | Platform | Call PlatformService — no module reads platform tables directly |
Service is the only public surface; direct table access is prohibited |
| All modules | Platform | resolveEntitlements(tenantId) / hasEntitlement(tenantId, code) at feature-gate |
Entitlement is the source of truth for feature access. Modules never hard-code tier limits. |
| Platform | identity | 8 platform columns → identity.identity_user (deferred FK, enforced at Phase 3 identity migration) |
Columns: tenant_contact.identity_user_id, tenant_entitlement.granted_by_user_id, agreement_acceptance.accepted_by_user_id, tenant_setup_task.completed_by_user_id, tenant_data_lifecycle.requested_by_user_id, tenant_lifecycle_event.actor_user_id, tenant_internal_activity.performed_by_user_id, operator_audit_log.operator_user_id. Plus open items: support-access/impersonation audit, console RBAC, tenant-side security events. |
| Platform | multi_loc | platform.tenant.primary_site_id → multi_loc.site (deferred FK — multi_loc.site now exists as of 2026-07-05, but the constraint is deliberately NOT wired this pass; wiring it reopens the locked platform module, tracked as a separate pass in OPEN_ITEMS) |
Location metering: tenant_usage_summary.sites_count tracks active sites; tier limit on additional locations in tier_definition. |
| identity | multi_loc | identity.tenant_user.default_site_id → multi_loc.site(id, tenant_id) — real composite FK as of 2026-07-10 (tenant_user_default_site_tenant_fkey), closing the prior deferral. See PROJECT_DECISIONS #54. |
A tenant user's default working site for multi-site staff. |
| identity | multi_loc | identity.user_site_assignment.site_id → multi_loc.site(id, tenant_id) — real composite FK as of 2026-07-10 (user_site_assignment_site_tenant_fkey), closing the prior deferral (the one blocking live orphaned row was investigated and deleted first — isolated dev-seed junk, see PROJECT_DECISIONS #54). |
Which sites a user is assigned to, for multi-site permission scoping. |
| identity | multi_loc | identity.user_permission_override.scope_id → multi_loc.site (deferred FK — multi_loc.site now exists as of 2026-07-05, but the constraint is deliberately NOT wired this pass; wiring it reopens the locked identity module, tracked as a separate pass in OPEN_ITEMS) |
Site-scoped permission overrides when scope_type='site'. |
| identity | multi_loc | identity.invitation_site_assignment.site_id → multi_loc.site(id, tenant_id) — real composite FK as of 2026-07-10 (invitation_site_assignment_site_tenant_fkey); was deliberately unwired at this column's own 2026-07-10 creation (PROJECT_DECISIONS #52), closed the same day (PROJECT_DECISIONS #54). |
Which site a pre-acceptance invitation stages a user to be assigned to. |
| Platform | payments | Reconciled 2026-07-07 at the payments module build (module #19, PROJECT_DECISIONS #33) — the naming ambiguity this row anticipated ("PaymentsService" reads as if it spans both billing directions) is now corrected: platform.subscription.status's column comment explicitly names a DISTINCT Vrida-own-billing ingestion service, not payments.PaymentsService. Stripe status-value normalization (canceled→cancelled etc.) is that distinct service's job, still unbuilt — see OPEN_ITEMS' reconciled platform-naming row. |
Merchant processing = payments module; Vrida-side SaaS billing = platform. Two distinct billing directions: platform owns Vrida→tenant (a direct Stripe integration); payments owns tenant→customers (Stripe Connect). Never the same service. |
| Platform | ai | AI credit enforcement: PlatformService checks balance + spend limit before an AI call; per-call consumption writes to ai_credit_transaction (open item — at AI module build). AI-driven dunning execution (adaptive retry timing, recovery prediction) also deferred to AI module + Vrida-billing vendor decision. |
Platform holds wallet state (ai_credit_account) and dunning state (subscription.dunning_*); AI module executes the logic. |
| Platform | files | platform.contract.document_ref, platform.agreement_version.document_url = R2 refs managed by Platform directly, not via FilesService. See Platform R2 exception note in §Files above. |
Signed legal docs and agreement PDFs are Platform-owned R2 refs; storage quota tracked in Files against platform entitlement. |
| Platform (internal) | Platform | platform.contract.entity_id/platform.billing_account.entity_id → platform.legal_entity.id (nullable FKs, Remediation Phase 4 Item 15, PROJECT_DECISIONS #40) |
Which legal entity within the tenant a Vrida-side contract or billing account belongs to, when a tenant operates multiple LLCs. 2 of 10 tables across the codebase carrying this column — see the Admin/Tax/Billing/Purchasing/Orders/POS sections above for the other 8. |
| Platform | (all modules, future) | platform.outbox (Remediation Phase 4 Item 20a, PROJECT_DECISIONS #40) — a durable transactional-outbox event table, no consumer/dispatcher service exists yet |
Reserved for closing the classic dual-write gap (domain write succeeds, event-publish fails) once an event-driven integration need arises; not consumed by any module today. |
Inventory Core protected-write boundary — 2026-07-15 task run
| From | To | Protected schema contract | Status |
|---|---|---|---|
| Returns | Inventory | returns.post_and_cap_return_receipt_line() is owned by NOLOGIN returns_invariant_owner. It locks and derives the receipt/authorization facts, then calls the private Inventory complete-posting primitive. One receipt line creates exactly one complete movement header/line and stock effect atomically; ordinary roles have neither Inventory DML nor function EXECUTE. |
Built and verified schema seam; no Returns service was added. |
| Receiving | Inventory | Existing composite evidence FKs remain. Inventory now rejects direct ordinary movement/cost mutation. No Receiving wrapper was authorized by this reopen; fixture-only owner writes do not create an executable path. | Future companion schema reopen before ReceivingService: freeze weighted-average/reversal arithmetic and add a narrow source-derived wrapper. |
| Transfer | Inventory | At the Inventory Core reopen checkpoint, Transfer values were deliberately dormant and unreachable. | Closed by the subsequent Stock Transfer schema build: authoritative tables were created before narrow locked-row wrappers in the same migration set. Runtime EXECUTE remains absent, and every non-Transfer path still rejects Transfer attribution. |
Stock Transfer schema contract — 2026-07-16 task run
| From | To | Mechanism | Contract |
|---|---|---|---|
| Transfer | Inventory Core | transfer_line.stock_reservation_id → stock_reservation(id, tenant_id) plus the protected reservation primitive |
Approval creates one aggregate reservation per effective line. Transfer attribution is derived from locked Transfer rows; callers cannot select source types, source identities, quantities, tenant, actor, or keys. |
| Transfer | Inventory Core | transfer_line.outbound_movement_line_id → stock_movement_line(id, tenant_id) |
Shipment creates one complete movement header and exactly one child for each effective Transfer line. The child UUID is immutable exact provenance; correlation_id alone is never used to resolve cost. |
| Transfer | Inventory lot master | transfer_line(lot_id, tenant_id) → lot(id, tenant_id) |
One optional durable lot per line. The same lot is pinned through source stock_lot, outbound movement line, inbound movement line, and destination stock_lot. Multi-lot quantity requires multiple lines. |
| Transfer | Multi-Location | Composite source/destination site FKs plus trg_site_block_nonterminal_transfer_soft_delete |
Source may be active/inactive/closed but nondeleted. Destination must be active and nondeleted at approval and shipment. Post-shipment deactivation does not strand receipt; soft deletion is blocked while nonterminal. |
| Transfer | Inventory Location | Composite source/destination location FKs plus trg_inventory_location_block_nonterminal_transfer_soft_delete |
Locations must match the pinned site and tenant. Destination is active at approval/shipment; receipt continues after later deactivation. Nonterminal references block soft deletion. |
| Transfer | Platform | transfer(entity_id, tenant_id) → platform.legal_entity(id, tenant_id) |
Optional legal-entity attribution only. No intercompany accounting behavior is implied or built. |
| Transfer | Notifications / Approvals / Purchasing | Documentation seam only | No notification, approval-workflow, suggestion/worklist, snooze, PO-draft, or runtime module call was built. |
Transfer wrapper execution remains intentionally dormant. No runtime role has consequential
EXECUTE; a future service phase must separately approve a non-spoofable credential/session
gateway before application wiring.