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 module
  • POSService — public API for POS module
  • OrdersService — 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 by InventoryService
  • Only InventoryService writes 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_changed
  • pos.sale_completed
  • orders.reservation_created
  • orders.reservation_fulfilled
  • crm.customer_created
  • platform.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_consent directly without going through CRMService
  • ❌ 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

  • POSService calling InventoryService.completeSale()
  • PricingService called at checkout to resolve price → sale_line.unit_price_cents
  • NotificationsService querying CRMService.getConsent() at send-time
  • BillingService writing back to purchasing.vendor_invoice via PurchasingService (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_tenderchk_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_orderCLOSED 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_idinventory.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_idmulti_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_receiptreceiving.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.stock mutation — belongs to Inventory exclusively. Receiving only ever writes inventory.stock_movement/stock_movement_line rows (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_refpos.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_ididentity.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 tablesintegration_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_workflowapproval_routing_ruleapproval_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.outboxNOT 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_codeshared.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_idmulti_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 / creditCORRECTED 2026-07-08, Remediation Phase 3 Item 8 (PROJECT_DECISIONS #39): the prior claim here — that credit_terms/credit_limit_cents were REMOVED from crm.customer entirely per DR-35 — was itself the bug. crm.customer OWNS credit_limit_cents/credit_terms (credit-worthiness is a relationship judgment about THIS customer, crm's domain); billing.ar_account READS 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; rewards has 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 offers has 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_idmulti_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 (never authority), never delegated to an agent.
  • Plant taxonomy / growing-environment data — belongs to the new nursery / nursery_ref schemas (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_reffiles.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_reffiles.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 int4bigint 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.actoragent_identitydecision_context_snapshotai.agent_executionagents.agent_decisionagents.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.actoragent_identitydecision_context_snapshotai.agent_executionagents.agent_decisionagents.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.


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 compositeconsumer.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 merchant authenticated role — REVOKE ALL at the ACL layer, not merely an absent grant, confirmed live via pg_catalog.
  • These 8 tables are reachable only through the new consumer_authenticated Postgres role (NOLOGIN NOINHERIT), driven by a consumerDB() connection helper mirroring tenantDB()'s own SET 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's apps/api never 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_authenticated has 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.
  • offers module = 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):

  • offers records 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 offers schema. 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. OffersService and RewardsService are 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 | " row for the same seam from that module's side; this Returns section remains the single authoritative, fullest account of the returns↔pos/orders/inventory/approvals/files seams.

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.stock mutation — belongs to Inventory exclusively. Returns only ever writes inventory.stock_movement/stock_movement_line rows, the same guard every other stock-posting module in this codebase carries.
  • Store-credit balance trackingRESOLVED 2026-07-18 (Phase 3 stored-value build, PROJECT_DECISIONS #71). billing.store_credit_account/store_credit_transaction now exist; return_resolution.store_credit_transaction_id is a real composite FK to the 'issue'-typed ledger entry (required for store_credit/warranty_credit resolutions via CHECK). The old store_credit_reference free-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-closedrewards (locked since #56/#60) does not back a reward TENDER: no posrewards 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 (canceledcancelled 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.

Last modified: Jul 16, 2026, 9:05 AM PT
On this page
Esc