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 PROJECT_DECISIONS.md lock entries. 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 (post-v1.0). 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 (applies when Reporting module is built)

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. Source: docs/DESIGN_RATIONALE.md.

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 (file_storage_usage → Files; 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 — spec pending)
  • ✅ Service classes returning DTOs defined in the source module

Cross-Module Seam Catalog

Actual seams between locked modules, sourced from PROJECT_DECISIONS.md lock entries. Every new lock must add its seams here (Section 6, item 11).

POS

From To Mechanism What flows
POS Inventory InventoryService.completeSale(idempotency_key) Stock decrement; creates inventory.stock_movement. idempotency_key prevents double-decrement on offline sync.
POS Pricing PricingService.resolvePrice() at checkout Resolved price → pos.sale_line.unit_price_cents.
POS Payments PaymentsService (Stripe Terminal) POS stores stripe_payment_intent_id; never calls Stripe directly.
POS Billing sale_payment.charge_account_ref text seam → billing.ar_charge Charge-account tender triggers A/R entry in Billing.
CRM POS customer_id nullable FK on pos.sale Anonymous sales OK. CRM customer linked when present.

Orders

From To Mechanism What flows
Orders Inventory InventoryService.reserve() at order confirmation Creates inventory.stock_reservation; order_line.stock_reservation_id holds reference.
Orders POS order_header.fulfilled_sale_id → pos.sale Link-don't-convert: order links to the POS sale that fulfilled it. Tax finalizes at POS.
Orders Purchasing order_header.draft_po_id (text seam → purchasing.purchase_order) Special orders that trigger a PO. FK enforced at purchasing lock.

Purchasing

From To Mechanism What flows
Purchasing Inventory InventoryService.receiveStock(source_module='purchasing') Received goods registered as inventory.stock_movement.

Billing

From To Mechanism What flows
POS / Orders Billing sale_payment.charge_account_ref / order_payment.charge_account_refbilling.ar_charge Charge-account tenders become A/R entries in Billing.
Billing Purchasing BillingService writes vendor_invoice.billing_ap_ref, payment_status_ref, paid_at via PurchasingService A/P control: when a vendor payable is paid, Billing writes back to the vendor invoice. PurchasingService writes no value here.
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).

Payments

From To Mechanism What flows
Payments POS PaymentsService writes payment outcome back to pos.sale_payment after Stripe webhook Terminal payment result (captured / failed / refunded).
Payments Billing Stripe event log in payments; PaymentsService writes settlement status to billing.ar_payment / ap_payment Settlement and refund outcomes.
Payments (all) Stripe webhooks processed by PaymentsService only — owner-processes-own-webhooks No other module processes Stripe events.

Pricing

From To Mechanism What flows
Pricing CRM price_rule.customer_id, price_list_assignment.customer_id / customer_group_id FKs Customer-specific and group price-list assignments resolved via CRM identity.
POS / Orders Pricing PricingService.resolvePrice() at checkout / order confirmation Line-item price resolution; final price stamped on the transaction line.

Notifications

From To Mechanism What flows
Notifications CRM CRMService.getConsent() queried at send-time; opt-outs written back via CRMService Consent check + opt-out write. Notifications owns ZERO consent tables — legally load-bearing.
Notifications Integrations NotificationsService.send()IntegrationsService → provider Provider call; provider_message_ref returned → stored on notifications.delivery_attempt. provider_call log in Integrations is mechanical-only.
Notifications Platform Read platform.tenant_entitlement (quota limit) Quota enforcement: Notifications owns usage counter; Platform owns tier limit.
Notifications Audit AuditService.log() after each send attempt Each delivery attempt logged to audit.audit_log. Reference-don't-copy.

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

From To Mechanism What flows
Admin Files tenant_branding.logo_ref, compliance_document.document_reffiles.file.id Brand logo (public), compliance PDFs (private).
CRM Files customer_tax_certificate.document_ref → files.file.id Tax cert PDFs (private).
POS Files sale.signature_ref, guarantee.signature_reffiles.file.id Canvas signature PNGs (private).
Purchasing Files purchase_receipt.shipment_photo_ref → files.file.id Receiving-dock photos (private).
Integrations Files sync_run.file_ref → files.file.id Import files / Picas CSV (private).
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).
Inventory Files item_image.file_id → files.file.id (additive col 2026-06-11) Product images (public).
Files Platform file_storage_usage (usage counter) vs platform.tenant_entitlement (tier limit) Storage quota enforcement: usage tracked in Files; limit owned by Platform.

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; see DESIGN_RATIONALE.md DR7.

AI

From To Mechanism What flows
AI Files import_file.file_id → files.file (FK enforced at AI migration); bytes via FilesService.getSignedUrl() Uploaded import files live in R2 (Files module). AI module never stores file bytes in Postgres — reference-don't-copy for blobs.
AI Shared AIService writes shared.plant (data_source='ai_generated', is_verified=false) + shared.plant_common_name aliases via service_role 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).
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

From To Mechanism What flows
CRM Consumer crm.customer.consumer_id → consumer.consumer (FK — READY; enforced when CRM migration runs the FK-add). Direction: tenant-scoped CRM → platform-scoped Consumer. The Model B bridge. A nursery's customer row optionally links to the platform-level consumer identity. One direction only — Consumer does not reference crm.*.
ConsumerService Consumer All reads/writes to consumer.* route through ConsumerService (SECURITY DEFINER / service-role). Tenant-context code NEVER queries consumer.* directly. Cross-store privacy enforcement boundary. A tenant receives only the consumer's name/email/phone (proxied) + its own consumer_tenant_link rows — never another nursery's relationship or the consumer's cross-store history.
Consumer Platform consumer_tenant_link.tenant_id → platform.tenant (enforced FK — dimension only, not RLS scope). Identifies which nursery a consumer link belongs to. Consumer RLS scopes on consumer_id, not tenant_id.
Consumer Shared consumer_address.country_code → shared.country (enforced FK). consumer_interest.interest_refshared.plant.slug (loose text ref — validated by ConsumerService, not FK-enforced). Address country validation; interest references plant catalog slugs.
rewards Consumer loyalty_account.consumer_id → consumer.consumer (enforced FK — DONE at rewards lock 2026-06-12). rewards.* is tenant-scoped; consumer_id is a dimension only. ConsumerService aggregates per-tenant balances for consumer app. Consumer Layer — rewards seam closed. See Rewards section below.
offers Consumer offer.consumer_id / offer_code.consumer_id / offer_assignment.consumer_id / offer_redemption.consumer_idconsumer.consumer (enforced FKs — DONE at offers lock 2026-06-12). Consumer identity foundation for targeting and redemption tracking. Consumer Layer — offers seam closed. See Offers section below.
consumer_app Consumer (forward — not yet designed) Will consume consumer.consumer + consumer_tenant_link as the identity foundation. Consumer Layer phase (post-v1.0).

Cross-store privacy contract (GUARD):

  • Tenants MUST NOT receive a direct query path to consumer.*.
  • ConsumerService is the ONLY entry point. Any proposed service method that reads consumer.* in a tenant-context request must route through ConsumerService.
  • A consumer's cross-store activity (links to other nurseries, interests, addresses, etc.) is NEVER visible to a tenant. The tenant sees only what ConsumerService explicitly exposes (name, email, phone when linked; opt-in status via consumer_tenant_link).
  • Never add a JOIN from a tenant-scoped query directly to consumer.* tables.

Rewards

From To Mechanism What flows
Rewards POS points_ledger.sale_id → pos.sale (enforced FK — DONE at rewards lock 2026-06-12). THE SEAM: POS records points_earned/points_redeemed as receipt-level snapshots on the sale row; RewardsService owns the authoritative ledger. POSService calls RewardsService.earn() / RewardsService.redeem() at sale time. Sale-triggered points movements. points_ledger references the sale; pos.sale snapshots the amounts. Reference-don't-copy.
Rewards Consumer loyalty_account.consumer_id → consumer.consumer (enforced FK — DONE at rewards lock). Tenant-scoped account row holds a dimension FK to the platform-level consumer identity. Consumer identity dimension on the loyalty account — identifies which consumer holds the account. NOT the RLS scope.
Rewards Platform rewards.*.tenant_id → platform.tenant (6 enforced FKs). RLS scope on all rewards tables. Tenant ownership of the loyalty program, earning rules, tiers, catalog, accounts, and ledger.
Rewards Identity points_ledger.created_by_user_id → identity.identity_user (enforced FK). Set on entry_type = 'adjust' (manual staff correction). Audit trail for manual balance adjustments — identifies the staff member who issued the correction.
Rewards CRM RewardsService reads crm.customer_consent (consent_type = 'loyalty') before crediting points. No FK. Loyalty consent check before every earn event. Consent lives in CRM; RewardsService reads it via CRMService.
ConsumerService Rewards ConsumerService aggregates loyalty_account rows across all nurseries for a given consumer (cross-store balance display in consumer app). Uses service-role read over tenant-scoped loyalty_account with index on consumer_id. Per-tenant balances aggregated for the consumer's "my rewards" view. No pooling across nurseries — per-business balances only.

reward_option vs offers boundary (GUARD):

  • 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.

Cross-store privacy (inherits Consumer contract):

  • Tenants access loyalty_account and points_ledger only for their own tenant_id — enforced by RLS.
  • A consumer's loyalty balances at other nurseries are NEVER visible to this nursery — each nursery sees only its own loyalty_account rows.
  • ConsumerService aggregates across nurseries for the consumer-facing view only.

Offers

From To Mechanism What flows
Offers POS offer_redemption.sale_id → pos.sale (enforced FK — DONE at offers lock 2026-06-12). THE REDEMPTION SEAM: the offer was applied at this POS sale. FK on offers side; pos.sale has NO offer seam columns — no POS additive touch. Same reference-don't-copy pattern as rewards.points_ledger.sale_id. OffersService validates and records redemption; POSService applies the discount amount. Redemption event ties offer to the triggering sale.
Offers Consumer offer.consumer_id / offer_code.consumer_id / offer_assignment.consumer_id / offer_redemption.consumer_idconsumer.consumer (enforced FKs — DONE at offers lock). Tenant-scoped offers tables; consumer_id is a targeting dimension on offer (nullable for broadcast/code) and NOT NULL on offer_assignment/offer_redemption. Consumer identity foundation for offer targeting, assignment lifecycle, and redemption tracking.
Offers Platform offers.*.tenant_id → platform.tenant (4 enforced FKs). RLS scope on all offers tables. Tenant ownership of the offer catalog, code pool, assignment lifecycle, and redemption ledger.
Offers CRM OffersService reads crm.customer_consent (consent_type = 'offers') before targeting or issuing any offer. No FK. Offers consent check before every targeted issuance. Consent lives in CRM; OffersService reads it via CRMService. Third instance of the CRM-owns-consent pattern.
ConsumerService Offers ConsumerService aggregates offer_assignment rows across all nurseries for a given consumer (cross-store available-offers display in consumer app). Uses service-role read over tenant-scoped offer_assignment with index on consumer_id. Per-tenant available offers aggregated for the consumer's "my offers" view.
POSService Offers + Rewards POSService calls OffersService.redeem(consumer_id, offer_id, sale_id) AND RewardsService.redeem(consumer_id, reward_option_id, amount) as parallel calls at checkout. OffersService is NOT a sub-service of RewardsService. stacking_policy on offer governs combination of multiple offers in one checkout. Two independent discount mechanisms applied at the same checkout. All three (pricing rules + offers + rewards redemptions) can stack.
Offers Platform (v1.5) When funding_source = 'vrida', OffersService will feed redemption facts to a future platform settlement concept. v1 is merchant-funded only; settlement infrastructure deferred. Offers records the fact; platform owns the financial ledger. Vrida→merchant reimbursement data feed (v1.5, not yet built).

Settlement boundary (GUARD):

  • 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.
  • v1 service layer rejects funding_source = 'vrida' offers — deferred to v1.5 against a future platform credit concept.

Offers vs rewards boundary (GUARD — see Rewards section above for the full GUARD block):

  • offers = coupon/promo issuance (discrete issued instruments). reward_option = points-funded redemption catalog.
  • Never merge. OffersService and RewardsService are parallel services. See Rewards GUARD block above.

Cross-store privacy (inherits Consumer contract):

  • Tenants access offer.* tables only for their own tenant_id — enforced by RLS.
  • A consumer's offer history at other nurseries is NEVER visible to this nursery.
  • ConsumerService aggregates across nurseries for the consumer-facing view (available offers cross-store).

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).

Platform

From To Mechanism What flows
Platform All modules Read platform.tenant_entitlement at feature-gate check Entitlement is the source of truth for feature access. Modules never hard-code tier limits.
Last modified: Jun 17, 2026, 6:57 PM PT
On this page
Esc