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.mdlock 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 modulePOSService— public API for POS moduleOrdersService— public API for orders module
Other modules import and call these services. They do not query tables.
Rule 2: Tables are private to their module
- Tables in
inventory.*schema are owned byInventoryService - Only
InventoryServicewrites to inventory tables - Cross-module reads MAY be allowed via controlled views (documented per module)
Rule 3: Events for one-to-many notifications
When a module's state changes meaningfully, it emits an event. Other modules subscribe.
Events use the NestJS event emitter or a message queue (post-v1.0). Example event names (non-exhaustive):
inventory.stock_changedpos.sale_completedorders.reservation_createdorders.reservation_fulfilledcrm.customer_createdplatform.subscription_changed(SaaS subscription events belong to Platform, not Billing — Billing is merchant A/R + A/P)
Rule 4: Synchronous calls for query/command operations
When POS needs "is this variant in stock?", it calls InventoryService.getAvailableQty(variant_id).
When POS completes a sale, it calls InventoryService.completeSale(idempotency_key, ...).
Rule 5: Asynchronous events for side effects
After InventoryService.completeSale() executes, an event is emitted. Notifications, Audit, and other modules subscribe to update their own state.
Rule 6: Materialized View Invalidation (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_consentdirectly without going throughCRMService - ❌ Any module calling the Stripe API directly (must go through
PaymentsService) - ❌ Any module calling Bedrock / R2 / external search directly (must go through the service layer)
- ❌ Modules sharing types/interfaces beyond what's exposed in their service contract
- ❌ Notifications owning consent or opt-out tables (those are CRM's)
Allowed Patterns
- ✅
POSServicecallingInventoryService.completeSale() - ✅
PricingServicecalled at checkout to resolve price →sale_line.unit_price_cents - ✅
NotificationsServicequeryingCRMService.getConsent()at send-time - ✅
BillingServicewriting back topurchasing.vendor_invoiceviaPurchasingService(documented seam) - ✅ Materialized views in
reporting.*joining across schemas (read-only, owned by Reporting — 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_ref → billing.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_ref → files.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_ref → files.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_ref → files.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). |
Search
| From | To | Mechanism | What flows |
|---|---|---|---|
| Search | Inventory | search_vector generated col on inventory.item + inventory.item_variant; queried via SearchService.search({module:'inventory', ...}) |
Product catalog search (item name/description/type) + variant search (SKU partial, fuzzy name). SearchService composes query; callers never query search_vector directly. |
| Search | CRM | search_vector generated col on crm.customer; queried via SearchService.search({module:'crm', ...}) |
Customer lookup by display_name, customer_number, company_name. Primary first step in two-step customer-name order/sale search. |
| Search | Purchasing | search_vector generated col on purchasing.vendor; queried via SearchService.search({module:'purchasing', ...}) |
Vendor search by name, code, legal_name. |
| Search | Orders | search_vector generated col on orders.order_header; queried via SearchService.search({module:'orders', ...}) |
Order lookup by order_number, po_number, job_reference. No customer-name snapshot — customer-name search requires CRM first step (see Composition Notes in SCHEMA.md § search). |
| Search | POS | search_vector generated col on pos.sale; queried via SearchService.search({module:'pos', ...}) |
Sale lookup by sale_number, po_number, job_reference. No customer-name snapshot — same two-step compose required. |
| All modules | Search | None — Search adds touches to source tables; source modules do NOT call SearchService. SearchService is a read-only query layer over source-table GIN indexes. |
Search is a pure read-layer; it introduces no writes to source tables beyond the DB-maintained generated columns. Tenant isolation: every SearchService query MUST include WHERE tenant_id = ?; GIN indexes carry no tenant partitioning. |
Reporting
| From | To | Mechanism | What flows |
|---|---|---|---|
| Reporting | POS / Orders / CRM / Inventory / Purchasing / Billing / Payments / Admin / Integrations / Notifications / Audit / Files / AI / Platform / Multi-loc | ReportingService reads via service_role (pooled port 6543, bypasses RLS intentionally — cross-schema reads cannot use tenant RLS policies). Read-only matview sources and live queries. No FK enforcement on these read paths. |
Source data for materialized views and live report queries. |
| Source modules | Reporting | Source modules emit *_changed events per Rule 6. ReportingService subscribes and refreshes affected matviews. Each MV declares its strategy (aggressive / staleness_window) and triggering event — see SCHEMA.md § reporting § Materialized View Specifications. |
MV invalidation signals. Source modules do NOT call ReportingService for writes — Reporting is a pure consumer. |
| Reporting | billing.ar_statement |
ReportingService reads billing.ar_statement at period-close time to populate period_close_snapshot.ar_outstanding_cents. |
AR outstanding summary figure. READS, does not shadow — Billing owns AR period snapshots; Reporting adds the cross-module summary. |
| Nothing | Reporting | Terminal — no module reads from reporting.* as a dependency. No FKs point INTO reporting.*. Zero forward-refs out. |
— |
MV → event subscriptions (full Rule-6 declarations — see SCHEMA.md § reporting):
| MV | Triggering event | Strategy |
|---|---|---|
mv_sales_summary |
sales_changed |
Aggressive |
mv_sales_by_customer |
sales_changed |
Staleness window 30s |
mv_inventory_valuation_current |
stock_changed |
Aggressive |
mv_gross_margin |
sales_changed |
Staleness window 30s |
mv_customer_rfm |
sales_changed (batch) |
Daily staleness |
mv_ar_aging |
ar_changed |
Staleness window 30s |
mv_ap_aging |
ap_changed |
Staleness window 30s |
mv_vendor_performance |
receipt_changed (batch) |
Daily staleness |
mv_sync_health |
sync_changed |
Staleness window 30s |
mv_notification_summary |
notification_changed |
Staleness window 30s |
Consumer
| 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_ref → shared.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_id → consumer.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.*. ConsumerServiceis the ONLY entry point. Any proposed service method that readsconsumer.*in a tenant-context request must route throughConsumerService.- A consumer's cross-store activity (links to other nurseries, interests, addresses, etc.) is NEVER visible to a tenant. The tenant sees only what
ConsumerServiceexplicitly exposes (name, email, phone when linked; opt-in status viaconsumer_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.offersmodule = coupon/promo issuance, including Vrida-funded offers with settlement economics. Never put a points-exchange redemption there.- If a feature seems to blend the two, it is a service-layer integration — not a schema merge.
Cross-store privacy (inherits Consumer contract):
- Tenants access
loyalty_accountandpoints_ledgeronly for their owntenant_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_accountrows. ConsumerServiceaggregates 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_id → consumer.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):
offersrecords offer definitions,funding_source, and redemption events. It does NOT own Vrida→merchant credit settlement.- When
funding_source = 'vrida', the financial settlement (Vrida crediting the nursery) is platform's domain — platform owns the Vrida↔tenant financial relationship. - Do NOT add settlement/credit/invoice/funding-ledger tables to the
offersschema. Offers feeds settlement; platform builds it. - 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.
OffersServiceandRewardsServiceare parallel services. See Rewards GUARD block above.
Cross-store privacy (inherits Consumer contract):
- Tenants access
offer.*tables only for their owntenant_id— enforced by RLS. - A consumer's offer history at other nurseries is NEVER visible to this nursery.
ConsumerServiceaggregates 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. |