orders — module #15, completing the sell path

7 tables, 175 columns — schema-locked 2026-07-07, reopened 2026-07-08 for Remediation Phase 4 (Item 15, +1 col), again 2026-07-10 for Header/Line Remediation fix #6 (order_line.sale_line_id, +1 col), and again 2026-07-20 for the Gap-Fill Batch's B10 fix (order_payment.status gains 'forfeited' + a new terminal-state guard trigger — CHECK/trigger only, no column change). orders is the sell path's commercial layer: quotes, orders, special orders, and preorders, with deposits/installment schedules and light fulfillment tracking — completing crm → pricing → inventory → orders → pos. Up from v1's 7 tables / 133 cols (see the Design-Phase Integrity blocks in module_spec/orders.md and PROJECT_DECISIONS #29 for the full v1→v2 delta-accounting record — this is the first module built under those rules). Depends on platform (tenant ownership, and now legal_entity), identity (actor FKs throughout), multi_loc (site), crm (customer, address), pricing (price_rule — Hard Contract 1 snapshot target), inventory (item_variant, stock_reservation), pos (sale, sale_payment, and — since 2026-07-10 — sale_line — the fulfillment-link seam), and shared (currency).

PROJECT_DECISIONS entries: #29 (design/lock), #40 (Remediation Phase 4, Item 15), #50 (Header/Line Remediation, fix #6), #74 (Gap-Fill Batch, B10 — forfeited deposits).

Global rules for this schema:

  • Uniform tenant-scoping — all 7 tables carry tenant_id NOT NULL FK → platform.tenant, no mixed-scope case anywhere in this module. All 7 have RLS enabled with a permissive tenant-isolation policy named <table>_tenant_isolation, USING/WITH CHECK against current_setting('app.current_tenant_id')::uuid. Plain index on tenant_id on all 7.
  • updated_at is trigger-maintained via platform.set_updated_at() on all 7 tables — no new one-off trigger function was needed at original 2026-07-07 lock (unlike pos's trg_pos_sale_requires_open_session); nothing in orders required a same-row-insufficient, cross-table-state check enforced at the DB level at that time. This changed on 2026-07-20 (Gap-Fill Batch, B10): order_payment now carries its own one-off guard trigger, trg_order_payment_guard_status — see the dedicated section below.
  • Soft delete on all 7 tables (deleted_at timestamptz, nullable).
  • Online-first, NOT offline-first — deliberately no offline-sync quintet (client_uuid/origin/sync_status/synced_at). Only a single, light, nullable idempotency_key on order_header for API-retry dedup, matching pricing.price_rule's own precedent, not pos's full offline quintet (Part D, D12, explicitly ruled the quintet out — orders is web/phone/email/counter, never an offline POS terminal).
  • Agent-as-actor attribution, continuing the canonical pattern — every *_actor_id column targets identity.actor (the polymorphic root), never identity.identity_user directly.
  • Autonomy treatment is per-table, selective. order_header, order_line, order_payment, order_fulfillment, order_template get the full autonomy column set (actor attribution, automation_source, the full review seam, decision_provenance) — each carries a real, live-effect decision surface (a quote can be agent-drafted; a price override, a substitution, and a fulfillment delay/shortfall are all real judgment calls). order_fulfillment_line/order_template_line get zero autonomy columns — pure denormalized split/tracking tables with no independent decision surface beyond what their parent row already gates, matching crm.customer_note's DR-38 reasoning and pos.pos_sync_conflict's "nothing for automation_source to record" reasoning for the same class of finding.
  • v1 baseline vs. this build — zero consolidation, pure addition. v1's docs/old/schema/schema_modules/schema_orders.md specified 7 tables / 133 cols. This build's delta: table count unchanged at 7, column count up from 133 to 173 (+40) — every one of v1's 7 tables survives BUILT, none consolidated or dropped (Block 2 of the retained Design-Phase Integrity record reports this honestly rather than manufacturing a consolidation). See PROJECT_DECISIONS #29 for the full per-table delta table.
  • Pricing's Hard Contract 1 honored verbatim on order_line. See dedicated section below — identical field names/types to pos.sale_line's own 6 snapshot fields.
  • Reservation and fulfillment-link seams both real, not deferredorder_line.stock_reservation_id → inventory.stock_reservation and order_header.fulfilled_sale_id → pos.sale / order_payment.pos_sale_payment_id → pos.sale_payment are enforced FKs, since all 3 targets exist live. See dedicated sections below.
  • A 4th real seam, added 2026-07-10 (Header/Line Remediation fix #6)order_line.sale_line_id → pos.sale_line is a nullable, composite FK (order_line_sale_line_id_tenant_fkey (sale_line_id, tenant_id) → pos.sale_line(id, tenant_id), not bare). Link-don't-convert, mirroring order_header.fulfilled_sale_id's own established asymmetry — no reciprocal column on pos.sale_line's side. See the dedicated section below.
  • A new terminal-status guard, added 2026-07-20 (Gap-Fill Batch, B10)order_payment.status gained a 7th value, 'forfeited', plus a new one-off trigger, trg_order_payment_guard_status, rejecting any transition OUT of 'forfeited' (same-value re-writes tolerated). CHECK/trigger-only — no column or table added. See the dedicated section below.

Cross-Phase Foreign Keys (orders)

Column Target Notes
*.tenant_id (all 7 tables) platform.tenant NOT NULL on all 7 tables.
order_header.site_id, order_fulfillment.site_id multi_loc.site.id NOT NULL on both.
order_header.customer_id crm.customer.id Nullable — NULL only for anonymous quotes (order_type='quote').
order_header.entity_id platform.legal_entity.id NEW (Remediation Phase 4, Item 15). Nullable — which legal entity within the tenant this order belongs to, when a tenant operates multiple LLCs; NULL = the tenant's primary entity. Header-only (no entity_id on any orders line/child table — inherited via the header's FK).
order_template.customer_id crm.customer.id Nullable — NULL = reusable across any customer.
order_fulfillment.ship_to_address_id crm.address.id Nullable; REAL FKcrm.address exists live. Required (CHECK) when fulfillment_type='ship'.
order_header.currency_code, order_line.currency_code, order_payment.currency_code shared.currency.iso_code NOT NULL on all 3, char(3). No hardcoded default on any of them (v1 had DEFAULT 'USD' on order_header — dropped in favor of service-resolved-from-site/customer, matching pos.sale's own no-default convention).
order_line.variant_id, order_fulfillment_line.variant_id, order_template_line.variant_id inventory.item_variant.id NOT NULL on all 3; order_line.variant_id and order_line.substituted_from_variant_id's target, ON DELETE RESTRICT on order_line.variant_id.
order_line.stock_reservation_id inventory.stock_reservation.id Nullable (NULL for backordered/non-stocked lines). REAL FK — the reservation seam, see dedicated section below.
order_line.sale_line_id pos.sale_line(id, tenant_id) Nullable (NULL until fulfillment). NEW (Header/Line Remediation fix #6, 2026-07-10). Composite FK, not bare — order_line_sale_line_id_tenant_fkey. The prerequisite UNIQUE(id, tenant_id) on pos.sale_line was laid in the prior batch's POS reopen (fix #8) anticipating this exact fix. See dedicated section below.
order_line.resolving_price_rule_id pricing.price_rule.id Nullable (NULL when no rule matched, bare base_price_cents used), ON DELETE RESTRICT. Part of Hard Contract 1.
order_header.fulfilled_sale_id pos.sale.id Nullable, REAL FK, ON DELETE RESTRICT. The pos-fulfillment seam — link-don't-convert, no reciprocal column on pos's side (by design).
order_payment.pos_sale_payment_id pos.sale_payment.id Nullable, REAL FK.
order_id on order_line, order_payment, order_fulfillment orders.order_header.id NOT NULL on all 3. Intra-schema.
order_fulfillment_id / order_line_id on order_fulfillment_line orders.order_fulfillment.id / orders.order_line.id NOT NULL on both. Intra-schema.
order_template_id on order_template_line orders.order_template.id NOT NULL. Intra-schema.
every *_actor_id column identity.actor Nullable throughout. Cross-schema, enforced.
order_header.draft_po_id — (deferred forward-ref) Plain nullable uuid, no FK — Purchasing schema doesn't exist in v2.
order_payment.stripe_payment_intent_id — (deferred forward-ref) Plain nullable text, no FK — Payments doesn't exist in v2.
order_payment.charge_account_ref — (deferred forward-ref) Plain nullable text, no FK — Billing doesn't exist in v2.
order_fulfillment.tracking_number, .carrier — (deferred forward-refs) Plain nullable text, no FK — a future Delivery/Shipping module (v1's own "Module 14" deferral, unchanged).

orders.order_header (45 cols)

The root commercial-order record — quote, order, special order, or preorder. Full autonomy treatment: a quote can be agent-drafted; confirmation/cancellation are real financial/inventory commitments (needs_approval when agent-initiated).

Tenant-scoped. tenant_id NOT NULL FK → platform.tenant. RLS enabled — permissive policy order_header_tenant_isolation.

Soft delete: deleted_at timestamptz, nullable.

updated_at: trigger-maintained via platform.set_updated_at().

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL FK → platform.tenant
site_id UUID NOT NULL FK → multi_loc.site
order_number text NOT NULL Human-facing sequential reference
order_type text NOT NULL 'order' CHECK IN (quote,order,special_order,preorder)
customer_id UUID nullable FK → crm.customer; nullable only for anonymous quotes
status text NOT NULL 'draft' CHECK IN (draft,quote_sent,confirmed,fulfilling,partially_fulfilled,fulfilled,closed,cancelled,expired)
priority text NOT NULL 'standard' CHECK IN (standard,rush,vip)
po_number text nullable Customer's PO (B2B)
job_reference text nullable Contractor job reference
subtotal_cents bigint NOT NULL 0 Service-maintained cache: SUM(order_line.line_subtotal_cents)
discount_total_cents bigint NOT NULL 0 Service-maintained cache
estimated_tax_cents bigint NOT NULL 0 Display-only — legal tax finalizes at POS
estimated_total_cents bigint NOT NULL 0 Service cache: subtotal - discount + estimated_tax
currency_code char(3) NOT NULL (none) FK → shared.currency.iso_code; no hardcoded default (deviates from v1's DEFAULT 'USD')
deposit_required_cents bigint nullable NULL = no deposit required
balance_due_cents bigint NOT NULL 0 Service cache: estimated_total - SUM(order_payment.amount_paid_cents WHERE status='paid')
quote_expires_at timestamptz nullable Required when order_type='quote' — real CHECK
accepted_at timestamptz nullable
accepted_by text nullable Preserved verbatim from v1 — free-form acceptance evidence
accepted_by_actor_id UUID nullable NEW — FK → identity.actor; coexists with accepted_by, does not replace it
requested_date date nullable
expected_date date nullable
draft_po_id UUID nullable DEFERRED forward-ref, no FK — Purchasing not built
fulfilled_sale_id UUID nullable REAL FKpos.sale, ON DELETE RESTRICT — the pos-fulfillment seam
entity_id UUID nullable NEW (Remediation Phase 4, Item 15) — FK → platform.legal_entity; which legal entity within the tenant this order belongs to (multi-LLC tenants); NULL = tenant's primary entity
idempotency_key text nullable API-driven order-creation dedup
note text nullable
attributes jsonb nullable '{}' Vertical/custom extension point — example shape TBD, see Open Items
created_by_actor_id UUID nullable FK → identity.actor (retargeted from v1's created_by_user_id)
confirmed_at timestamptz nullable Required once status reaches confirmed or later — widened beyond v1's literal single-state text (deliberate, see module_spec/orders.md DR)
confirmed_by_actor_id UUID nullable FK → identity.actor (retargeted)
cancelled_at timestamptz nullable
cancelled_by_actor_id UUID nullable FK → identity.actor (retargeted)
cancellation_reason text nullable
closed_at timestamptz nullable
automation_source text NOT NULL 'human' CHECK IN (human,agent,system,seed)
review_status text NOT NULL 'not_required' CHECK IN (not_required,pending,approved,rejected)
review_reason text nullable
reviewed_by_actor_id UUID nullable FK → identity.actor; cannot equal created_by_actor_id when both set
reviewed_at timestamptz nullable
decision_provenance jsonb nullable memory_refs/delegated_by_actor_id extension, project-wide convention
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now() Trigger-maintained
deleted_at timestamptz nullable Soft delete

Dropped vs. v1: search_vector (Search module doesn't exist in v2).

CHECK constraints (10): chk_order_header_order_type; chk_order_header_status; chk_order_header_priority; chk_order_header_automation_source; chk_order_header_review_status; chk_order_header_reviewer_not_creator; chk_order_header_quote_requires_expiry (order_type != 'quote' OR quote_expires_at IS NOT NULL — was v1 service-only, now real); chk_order_header_customer_required_unless_quote (order_type = 'quote' OR customer_id IS NOT NULL — was v1 service-only, now real); chk_order_header_confirmed_requires_timestamp (widened, see above); chk_order_header_cancelled_requires_actor_reasonthe adversarial-caught fix: status != 'cancelled' OR (cancelled_at IS NOT NULL AND cancelled_by_actor_id IS NOT NULL AND cancellation_reason IS NOT NULL), requiring all 3 fields (an early draft only required 2 — caught before build, see PROJECT_DECISIONS #29).

Indexes: PK on id; plain index on tenant_id; (tenant_id, status, requested_date) — order dashboard filter; (tenant_id, customer_id) WHERE deleted_at IS NULL — customer's open orders; (tenant_id, quote_expires_at) WHERE order_type='quote' AND status='quote_sent' — quote-expiry sweep; order_header_tenant_id_order_number_unique (UNIQUE, tenant_id+order_number, WHERE deleted_at IS NULL); two-partial NULL-actor-safe idempotency uniques (matching pricing.price_rule's precedent): order_header_tenant_actor_idempotency_unique (tenant_id,created_by_actor_id,idempotency_key WHERE all NOT NULL AND deleted_at IS NULL), order_header_tenant_null_actor_idempotency_unique (tenant_id,idempotency_key WHERE created_by_actor_id IS NULL AND idempotency_key IS NOT NULL AND deleted_at IS NULL).

DEFERRED, not built: GIN (search_vector) (Search module doesn't exist); GIN (order_number gin_trgm_ops) (pg_trgm extension not enabled — same already-logged gap as inventory's identical deferral). Confirmed absent live.


orders.order_line (39 cols) — honors Pricing's Hard Contract 1 verbatim

One row per item on an order. Full autonomy treatment (upgraded from an earlier partial-pack draft after independent adversarial review found the partial version unjustified — see PROJECT_DECISIONS #29) — this table carries live financial judgment calls (price_override, a substitution proposal).

Pricing's locked Hard Contract 1, honored verbatim — the 6 required snapshot fields below are the historical record of what resolvePrice() returned at order-confirmation time, never re-derived from the live (possibly-since-superseded) price_rule row. Live-tested: after the resolving price_rule is superseded to a new price, order_line's snapshot amounts remain unchanged.

Tenant-scoped. tenant_id NOT NULL FK → platform.tenant. RLS enabled — permissive policy order_line_tenant_isolation.

Soft delete: deleted_at timestamptz, nullable.

updated_at: trigger-maintained via platform.set_updated_at().

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL FK → platform.tenant
order_id UUID NOT NULL FK → orders.order_header
variant_id UUID NOT NULL FK → inventory.item_variant, ON DELETE RESTRICT
line_type text NOT NULL 'standard' CHECK IN (standard,comp,substitution) — preserved verbatim
quantity numeric NOT NULL CHECK > 0
reserved_qty numeric NOT NULL 0 CHECK >= 0
fulfilled_qty numeric NOT NULL 0 CHECK >= 0
backordered_qty numeric NOT NULL 0 CHECK >= 0
cancelled_qty numeric NOT NULL 0 CHECK >= 0
fulfillment_status text NOT NULL 'pending' CHECK IN (pending,reserved,backordered,partially_fulfilled,fulfilled,cancelled)
stock_reservation_id UUID nullable REAL FKinventory.stock_reservation — the reservation seam; NULL for backordered/non-stocked lines
sale_line_id UUID nullable NEW (Header/Line Remediation fix #6, 2026-07-10)composite FKpos.sale_line(id, tenant_id) (order_line_sale_line_id_tenant_fkey), not bare. NULL until fulfillment; link-don't-convert, no reciprocal column on pos.sale_line's side
resolved_amount_minor_units bigint NOT NULL Hard Contract 1, field 1
charged_amount_minor_units bigint NOT NULL Hard Contract 1, field 2
currency_code char(3) NOT NULL Hard Contract 1, field 3. FK → shared.currency.iso_code
tax_treatment text NOT NULL Hard Contract 1, field 4. CHECK IN (inclusive,exclusive)
resolving_price_rule_id UUID nullable Hard Contract 1, field 5. FK → pricing.price_rule, ON DELETE RESTRICT; NULL when no rule matched
resolved_quantity numeric NOT NULL Hard Contract 1, field 6. CHECK > 0
price_override boolean NOT NULL false Preserved from v1 — a capability pos.sale_line itself dropped; orders restores it
price_override_reason text nullable Required when price_override=true — real CHECK
discount_amount_cents bigint NOT NULL 0
line_subtotal_cents bigint NOT NULL 0 Service cache
line_total_cents bigint NOT NULL 0 Service cache
estimated_line_tax_cents bigint NOT NULL 0 Display-only
substitution_allowed boolean NOT NULL true Preserved
substituted_from_variant_id UUID nullable FK → inventory.item_variant, preserved
expected_arrival_at timestamptz nullable
note text nullable Required when line_type != 'standard' — real CHECK
created_by_actor_id UUID nullable FK → identity.actor
automation_source text NOT NULL 'human' CHECK IN (human,agent,system,seed)
review_status text NOT NULL 'not_required' CHECK IN (not_required,pending,approved,rejected)
review_reason text nullable
reviewed_by_actor_id UUID nullable FK → identity.actor; cannot equal created_by_actor_id
reviewed_at timestamptz nullable
decision_provenance jsonb nullable
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now() Trigger-maintained
deleted_at timestamptz nullable Soft delete

Removed vs. v1: unit_price_cents — replaced (additive, not a reduction) by the 6-field Hard Contract 1 snapshot.

CHECK constraints (13): chk_order_line_line_type; chk_order_line_quantity_positive; chk_order_line_qty_columns_nonneg; chk_order_line_fulfillment_status; chk_order_line_quantity_rollup (reserved_qty + fulfilled_qty + backordered_qty + cancelled_qty <= quantity — was v1 service-only, now real); chk_order_line_tax_treatment; chk_order_line_resolved_quantity_positive; chk_order_line_price_override_reason (NOT price_override OR price_override_reason IS NOT NULL — was v1 service-only, now real); chk_order_line_note_required_for_special_types (line_type = 'standard' OR note IS NOT NULL — was v1 service-only, now real); chk_order_line_amounts_nonnegative; chk_order_line_automation_source; chk_order_line_review_status; chk_order_line_reviewer_not_creator.

Indexes: PK on id; plain indexes on tenant_id, order_id; (order_id, fulfillment_status) — backordered/partial status per order; (tenant_id, variant_id) — cross-order lines by variant.


orders.order_payment (27 cols)

Payment schedule / installment record. One row per deposit, milestone, or balance payment. Full autonomy treatment — money-touching (Part C's C8: independent approval seam + reversibility, both required).

Tenant-scoped. tenant_id NOT NULL FK → platform.tenant. RLS enabled — permissive policy order_payment_tenant_isolation.

Soft delete: deleted_at timestamptz, nullable.

updated_at: trigger-maintained via platform.set_updated_at().

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL FK → platform.tenant
order_id UUID NOT NULL FK → orders.order_header
sequence integer NOT NULL Installment order (1 = deposit)
payment_stage text NOT NULL CHECK IN (deposit,milestone,balance,final)
status text NOT NULL 'scheduled' CHECK IN (scheduled,due,paid,failed,refunded,cancelled,forfeited) — 'forfeited' added 2026-07-20, Gap-Fill Batch B10; terminal, guarded by trg_order_payment_guard_status (see below)
amount_due_cents bigint NOT NULL CHECK >= 0
amount_paid_cents bigint NOT NULL 0 CHECK >= 0
currency_code char(3) NOT NULL (none) FK → shared.currency.iso_code; no hardcoded default
due_at timestamptz nullable
paid_at timestamptz nullable
payment_method text nullable CHECK NULL OR IN (card,cash,check,ach,charge_account) — preserves v1's own exclusion of gift_card/store_credit (GAP F-1), consistent with pos's fail-closed decision on the same tenders
stripe_payment_intent_id text nullable DEFERRED — Payments module not built
charge_account_ref text nullable DEFERRED — Billing module not built
pos_sale_payment_id UUID nullable REAL FKpos.sale_payment
refunded_amount_cents bigint NOT NULL 0 CHECK >= 0 AND <= amount_paid_cents
note text nullable
created_by_actor_id UUID nullable FK → identity.actor
automation_source text NOT NULL 'human' CHECK IN (human,agent,system,seed)
review_status text NOT NULL 'not_required' CHECK IN (not_required,pending,approved,rejected)
review_reason text nullable
reviewed_by_actor_id UUID nullable FK → identity.actor; cannot equal created_by_actor_id
reviewed_at timestamptz nullable
decision_provenance jsonb nullable
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now() Trigger-maintained
deleted_at timestamptz nullable Soft delete

No columns dropped vs. v1 — pure addition (+7 autonomy pack).

CHECK constraints (10, count unchanged by the 2026-07-20 widening — chk_order_payment_status itself was widened in place, not replaced): chk_order_payment_payment_stage; chk_order_payment_status (widened 2026-07-20, Gap-Fill Batch B10, to add 'forfeited'); chk_order_payment_payment_method; chk_order_payment_amount_due_nonneg; chk_order_payment_amount_paid_nonneg; chk_order_payment_refunded_nonneg; chk_order_payment_refunded_le_paid; chk_order_payment_automation_source; chk_order_payment_review_status; chk_order_payment_reviewer_not_creator.

Trigger (new 2026-07-20, Gap-Fill Batch B10): trg_order_payment_guard_status (BEFORE UPDATE OF status, function orders.guard_order_payment_status()) — a no-op-plus-terminal-reject guard: a same-value re-write of status is tolerated (idempotent no-op), but any transition OUT of 'forfeited' is rejected. 'forfeited' is the only value this trigger polices; the other 6 order_payment statuses have no transition rule (they branch, not chain — see module_spec/orders.md §9 DR-K). See the dedicated section below.

Indexes: PK on id; plain indexes on tenant_id, order_id; order_payment_overdue_sweep_idx (tenant_id,status,due_at WHERE status IN ('scheduled','due')) — overdue-payment sweep.


orders.order_fulfillment (26 cols)

Fulfillment batch header. One row per fulfillment event for an order (a single order may span multiple batches — partial ship + later pickup). Full autonomy treatment — this is where a fulfillment delay or picking shortfall gets flagged as an anomaly, matching pos's own reasoning for why sale/register_session get the full pack.

Tenant-scoped. tenant_id NOT NULL FK → platform.tenant. RLS enabled — permissive policy order_fulfillment_tenant_isolation.

Soft delete: deleted_at timestamptz, nullable.

updated_at: trigger-maintained via platform.set_updated_at().

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL FK → platform.tenant
order_id UUID NOT NULL FK → orders.order_header
site_id UUID NOT NULL FK → multi_loc.site
fulfillment_type text NOT NULL CHECK IN (pickup,delivery,ship,counter_handoff)
status text NOT NULL 'pending' CHECK IN (pending,picking,staged,ready,handed_over,cancelled)
staging_code text nullable
picked_by_actor_id UUID nullable FK → identity.actor (retargeted from v1's picked_by)
picked_at timestamptz nullable
staged_at timestamptz nullable
ready_at timestamptz nullable
handed_over_at timestamptz nullable
ship_to_address_id UUID nullable REAL FKcrm.address; required when fulfillment_type='ship' — real CHECK
tracking_number text nullable DEFERRED — future Delivery/Shipping module
carrier text nullable DEFERRED — same
note text nullable
created_by_actor_id UUID nullable FK → identity.actor
automation_source text NOT NULL 'human' CHECK IN (human,agent,system,seed)
review_status text NOT NULL 'not_required' CHECK IN (not_required,pending,approved,rejected) — the delay/picking-shortfall flagging surface
review_reason text nullable
reviewed_by_actor_id UUID nullable FK → identity.actor; cannot equal created_by_actor_id
reviewed_at timestamptz nullable
decision_provenance jsonb nullable
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now() Trigger-maintained
deleted_at timestamptz nullable Soft delete

No columns dropped vs. v1 — pure addition (+7 autonomy pack).

CHECK constraints (6): chk_order_fulfillment_type; chk_order_fulfillment_status; chk_order_fulfillment_ship_to_required (fulfillment_type != 'ship' OR ship_to_address_id IS NOT NULL — was v1 service-only, now real); chk_order_fulfillment_automation_source; chk_order_fulfillment_review_status; chk_order_fulfillment_reviewer_not_creator.

Indexes: PK on id; plain indexes on tenant_id, order_id; order_fulfillment_status_idx (status) — all picking/staged/ready fulfillments across sites; order_fulfillment_site_id_status_idx (site_id,status) — staged/ready batches at a site, fulfillment dashboard.


orders.order_fulfillment_line (13 cols) — byte-for-byte unchanged from v1

One row per order line per fulfillment batch — tracks the split of a line's quantity across multiple batches and the picking outcome for each. Deliberately ZERO autonomy columns — a pure denormalized tracking/split table with no independent decision surface of its own beyond what its parent order_fulfillment already gates (matches crm.customer_note's DR-38 reasoning and pos_sync_conflict's reasoning for the same class of finding).

Tenant-scoped. tenant_id NOT NULL FK → platform.tenant. RLS enabled — permissive policy order_fulfillment_line_tenant_isolation.

Soft delete: deleted_at timestamptz, nullable.

updated_at: trigger-maintained via platform.set_updated_at().

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL FK → platform.tenant
order_fulfillment_id UUID NOT NULL FK → orders.order_fulfillment
order_line_id UUID NOT NULL FK → orders.order_line
variant_id UUID NOT NULL FK → inventory.item_variant
quantity_requested numeric NOT NULL CHECK > 0
quantity_picked numeric NOT NULL 0 CHECK >= 0 AND <= quantity_requested
quantity_fulfilled numeric NOT NULL 0 CHECK >= 0 AND <= quantity_requested
status text NOT NULL 'pending' CHECK IN (pending,picked,fulfilled,short,cancelled)
note text nullable
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now() Trigger-maintained
deleted_at timestamptz nullable Soft delete

Zero change vs. v1. CHECK constraints: chk_order_fulfillment_line_quantity_requested_positive; chk_order_fulfillment_line_quantity_picked_bounds; chk_order_fulfillment_line_quantity_fulfilled_bounds; chk_order_fulfillment_line_status.

Indexes: PK on id; plain indexes on tenant_id, order_fulfillment_id, order_line_id.


orders.order_template (16 cols)

Named recurring order template, applied via OrderService.applyTemplate() to create a new order_header pre-populated with lines. Full autonomy treatment — an agent could propose a template from a detected repeat-order pattern.

Prices are deliberately NOT stored here (preserved from v1) — resolved fresh by PricingService/resolvePrice() when the template is applied, never cached.

Tenant-scoped. tenant_id NOT NULL FK → platform.tenant. RLS enabled — permissive policy order_template_tenant_isolation.

Soft delete: deleted_at timestamptz, nullable.

updated_at: trigger-maintained via platform.set_updated_at().

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL FK → platform.tenant
customer_id UUID nullable FK → crm.customer; NULL = reusable across any customer
name text NOT NULL Unique per tenant while not deleted
is_active boolean NOT NULL true
note text nullable Carried to order on apply
automation_source text NOT NULL 'human' CHECK IN (human,agent,system,seed)
created_by_actor_id UUID nullable FK → identity.actor
review_status text NOT NULL 'not_required' CHECK IN (not_required,pending,approved,rejected)
review_reason text nullable
reviewed_by_actor_id UUID nullable FK → identity.actor; cannot equal created_by_actor_id
reviewed_at timestamptz nullable
decision_provenance jsonb nullable
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now() Trigger-maintained
deleted_at timestamptz nullable Soft delete

CHECK constraints: chk_order_template_automation_source; chk_order_template_review_status; chk_order_template_reviewer_not_creator.

Indexes: PK on id; plain index on tenant_id; (tenant_id, customer_id) WHERE is_active = true — active templates by customer; order_template_tenant_id_name_unique (UNIQUE, tenant_id+name, WHERE deleted_at IS NULL).


orders.order_template_line (9 cols) — byte-for-byte unchanged from v1

One row per item in an order template. Default quantities, adjustable at apply time. Prices not stored (see order_template). Deliberately ZERO autonomy columns — same reasoning as order_fulfillment_line.

Tenant-scoped. tenant_id NOT NULL FK → platform.tenant. RLS enabled — permissive policy order_template_line_tenant_isolation.

Soft delete: deleted_at timestamptz, nullable.

updated_at: trigger-maintained via platform.set_updated_at().

Column Type Nullable Default Constraints / Notes
id UUID NOT NULL gen_random_uuid() PK
tenant_id UUID NOT NULL FK → platform.tenant
order_template_id UUID NOT NULL FK → orders.order_template
variant_id UUID NOT NULL FK → inventory.item_variant
quantity numeric NOT NULL Default quantity, adjustable at apply time
note text nullable
created_at timestamptz NOT NULL now()
updated_at timestamptz NOT NULL now() Trigger-maintained
deleted_at timestamptz nullable Soft delete

Zero change vs. v1. Indexes: PK on id; plain indexes on tenant_id, order_template_id.


The reservation seam

order_line.stock_reservation_id → inventory.stock_reservation is a real FK, not deferred — stock_reservation.source_type already includes 'order' as a CHECK-enum value, and stock_movement.source_module already includes 'orders' (confirmed live before this build) — this table was architecturally shaped to receive orders as a primary writer before orders itself existed. The reserve-vs-move distinction is inventory's own, unchanged: a reservation (at order confirmation) is a soft-hold that reduces stock.available_qty via reserved_qty without moving physical stock; fulfillment later writes a real stock_movement row (the actual decrement) — a service-layer concern, not this schema's. Reserve-on-confirm, not on quote (v1's own rule, preserved) — backordered lines correctly hold stock_reservation_id = NULL.

The pos-fulfillment seam

order_header.fulfilled_sale_id → pos.sale and order_payment.pos_sale_payment_id → pos.sale_payment are both real FKs, since both targets exist live. pos.md itself has zero mention of orders or this seam — CROSS_MODULE_CONTRACTS.md is the actual seam catalog and correctly documents it exactly as expected for a (at the time) not-yet-built consumer: "FK lives entirely on Orders' side, pos.sale needs no reciprocal column," "link-don't-convert: order links to the POS sale that fulfilled it, tax finalizes at POS." Live-tested: deleting a pos.sale row referenced by an order_header.fulfilled_sale_id is rejected (ON DELETE RESTRICT, 23503/order_header_fulfilled_sale_id_fkey) — proving this is a real, enforced FK, not an unvalidated forward-ref.

order_line.sale_line_id → pos.sale_line extends the header-level pos-fulfillment seam above down to the line level — which sale_line a given order_line was actually fulfilled by, not just which sale fulfilled the order as a whole. Nullable (NULL until fulfillment happens), and a composite FK (order_line_sale_line_id_tenant_fkey (sale_line_id, tenant_id) → pos.sale_line(id, tenant_id)), never a bare single-column FK — the standing cross-tenant-FK rule this entire remediation effort exists to enforce. Link-don't-convert, matching order_header.fulfilled_sale_id's own established asymmetry exactly: no reciprocal column exists on pos.sale_line's side either, and none is planned unless a real "given a sale_line, find its originating order_line" query need emerges. The prerequisite UNIQUE(id, tenant_id) on pos.sale_line was laid in the prior batch's POS reopen (fix #8, PROJECT_DECISIONS #46) specifically anticipating this exact fix. Fully additive — nullable column, zero backfill, no existing order_line data touched. Live-tested: a same-tenant sale_line_id reference resolves via JOIN and succeeds; NULL succeeds (not-yet-fulfilled lines); a cross-tenant sale_line_id reference is rejected by the composite FK (23503/order_line_sale_line_id_tenant_fkey). See PROJECT_DECISIONS #50.

The forfeited-deposit terminal-status guard (Gap-Fill Batch B10, 2026-07-20)

order_payment.status gained a 7th CHECK-enum value, 'forfeited' (chk_order_payment_status widened in place — no new constraint, no column added), distinguishing a customer-forfeited deposit (special order cancelled by the customer, tenant keeps the deposit per stated policy) from 'cancelled' (payment never completed, no money changed hands) or 'refunded' (money returned) — a status meaning that did not previously exist.

A new function, orders.guard_order_payment_status(), and a matching BEFORE UPDATE OF status trigger, trg_order_payment_guard_status, enforce 'forfeited' as terminal: a same-value re-write ('forfeited' → 'forfeited') is tolerated as an idempotent no-op, but any transition OUT of 'forfeited' to any other value is rejected. This mirrors notifications.delivery_attempt's own established monotonic-guard shape — selected because neither order_header nor order_payment carried any status-transition trigger beyond platform.set_updated_at() before this pass, so there was no true precedent on this table itself to match. The other 6 order_payment statuses (scheduled,due,paid,failed,refunded,cancelled) remain entirely unguarded by this trigger — they branch rather than chain (e.g. a scheduled/due row can resolve to paid, failed, or cancelled; a paid row can later become refunded), so no single ordinal ranking exists to encode, and this fix's own scope was exactly one rule: forfeited is terminal. See module_spec/orders.md §9 (DR-K) and §15, and PROJECT_DECISIONS #74.

Deliberately NOT decided by this fix: whether a 'forfeited' payment still counts toward order_header.balance_due_cents's service-maintained derivation (§6/DR-4 of the module spec), and whether a forfeited deposit posts anywhere in billing, are both open questions left for OrderService's own future build — this fix adds only the status value and its terminal-transition guard.

Migration: packages/db/migrations/20260720000007_orders_reopen_forfeited_deposits.sql. Tests: orders-schema.spec.ts +4 (due→forfeited transition; forfeited→refunded rejected; same-value no-op tolerated; an unrelated non-forfeited row's normal transition unaffected), 51/51 for the file.

Pricing's Hard Contract 1 — honored verbatim on order_line

order_line carries all 6 fields Pricing's Hard Contract 1 requires (PROJECT_DECISIONS #26), verbatim-matching pos.sale_line's own field names/types: resolved_amount_minor_units, charged_amount_minor_units, currency_code, tax_treatment, resolving_price_rule_id, resolved_quantity. Live-tested: after the resolving price_rule row is superseded to a new price, order_line's snapshot amounts remain unchanged — the order line is the historical record of what was resolved at confirmation time, never re-derived from the live rule. This satisfies Hard Contract 1 from the orders side — mark that contract row as SATISFIED by both pos and orders in CROSS_MODULE_CONTRACTS.md.


orders — Design Patterns Summary

Column-count reconciliation

Table Cols
order_header 45
order_line 39
order_payment 27
order_fulfillment 26
order_fulfillment_line 13
order_template 16
order_template_line 9
Total 175

Verified live via information_schema.columns GROUP BY, schema orders: 7 tables, 175 columns (up from 174 at 2026-07-08/Remediation Phase 4, +1 from the 2026-07-10 Header/Line Remediation fix #6's order_line.sale_line_id; 173 at the original 2026-07-07 lock before that; unchanged again by the 2026-07-20 Gap-Fill Batch's B10 fix — CHECK/trigger-only, no column), matching the per-table sum exactly. 46 CHECK constraints total (verified live via pg_constraint, unchanged by fix #6 or by B10 — B10 widened chk_order_payment_status in place rather than adding a new CHECK), 45 FK constraints total (+1, order_line.sale_line_id → pos.sale_line, composite), RLS enabled on all 7 tables, platform.set_updated_at() firing on all 7, plus (new as of the 2026-07-20 Gap-Fill Batch) one additional one-off trigger — order_payment.trg_order_payment_guard_status (no new one-off trigger function had been needed by either of the two earlier reopens).

v1 deviations (full detail: PROJECT_DECISIONS #29)

Zero tables consolidated or dropped — pure addition (+40 cols across the same 7 tables). search_vector deferred (Search module doesn't exist in v2, matches every other module); the order_number pg_trgm fuzzy-search index deferred (extension not enabled, matches inventory's identical deferral); draft_po_id/stripe_payment_intent_id/charge_account_ref/tracking_number/carrier all remain forward-refs into not-yet-built modules (Purchasing/Payments/Billing/a future Delivery module). 6 v1 service-enforced-only rules promoted to real DB CHECKs. accepted_by preserved verbatim AND joined by a new accepted_by_actor_id (both, not either). 3 forward-refs upgraded from "documented as real" at v1's design time to actually-real FKs now that their targets exist live: fulfilled_sale_id→pos.sale, pos_sale_payment_id→pos.sale_payment, ship_to_address_id→crm.address.

Remediation Phase 4 (2026-07-08)

orders was reopened as 1 of 11 modules touched by Remediation Plan Phase 4 (Item 15 — Legal Entity; see PROJECT_DECISIONS #40). platform.legal_entity (1:N from platform.tenant, so a tenant can incorporate a 2nd LLC without splitting into two tenants) was added in this phase, along with a nullable entity_id FK column on 10 header tables across 8 modules independently derived for this rollout — orders.order_header is one of them.

order_header.entity_id (UUID, nullable, FK → platform.legal_entity.id) records which legal entity within the tenant an order belongs to, when the tenant operates multiple LLCs; NULL means the tenant's primary entity. This is a pure additive change — table count stays at 7, column count goes from 173 to 174 (order_header alone: 44 → 45).

Per the disclosed scoping rule established this same phase, entity_id was added ONLY to the orders module's header table (order_header), never to its line/child tables (order_line, order_payment, order_fulfillment, order_fulfillment_line, order_template, order_template_line) — unlike tenant_id, which this codebase applies uniformly to every table including line items, a line item's legal entity is always inherited via its header's FK and never needs its own column.

0 live order_header rows required backfill at build time (confirmed in the migration itself) — zero backfill risk. Migration: packages/db/migrations/20260709010000_phase4_item15_legal_entity.sql.

LIVE-REPRODUCED (per PROJECT_DECISIONS #40): order_header.entity_id confirmed nullable and FK-valid against platform.legal_entity, consistent with the other 9 header tables in this rollout.

Header/Line Remediation reopen (2026-07-10) — fix #6

Second reopen of the second batch of the coordinated "Header/Line Remediation" effort — Inventory landed first in this batch (fixes #5/#9, PROJECT_DECISIONS #49); orders is next, sequenced after Inventory and after POS specifically, per the confirmed ripple dependency in vrida-header-line-remediation-design-2026-07-10.md §6a (the prerequisite pos.sale_line UNIQUE(id, tenant_id) was laid in the first batch's POS reopen, fix #8). See PROJECT_DECISIONS #50 for the full record, including the pasted, attributed independent-verification summary.

  • orders.order_line.sale_line_id — new nullable column (+1 col), composite FK (order_line_sale_line_id_tenant_fkey) → pos.sale_line(id, tenant_id), not bare. Line-level fulfillment linkage — link-don't-convert, matching order_header.fulfilled_sale_id's own established asymmetry (no reciprocal column on POS's side either). Fully additive: nullable column, zero backfill, no existing order_line data touched.
  • Column-count impact: +1 column, 0 new tables — 174 → 175 columns, 7 tables unchanged — verified live via information_schema.columns, matching the per-table sum exactly (see the Column-count reconciliation table above).
  • Independent verification (separate agent, adversarial, live-DB-checked against a fresh transaction) — pasted, attributed. Found: the core fix #6 deliverable is fully verified and correct — composite FK confirmed genuinely 2-column via pg_constraint, the prerequisite UNIQUE(id, tenant_id) on pos.sale_line confirmed present, all 3 live-reproduction scenarios (valid same-tenant reference succeeds, NULL succeeds, cross-tenant reference rejected by order_line_sale_line_id_tenant_fkey) independently reproduced with matching error text, TS source confirmed clean (no dangling imports, no .references() on the raw column), grants unchanged, and the design's stated asymmetry (no reciprocal column on pos.sale_line) confirmed to actually hold.
  • 2 findings disclosed, neither invalidating fix #6 itself: (1) a test-suite bug caught DURING verification, not by the build itself — the new section's afterAll originally attempted a hard DELETE FROM pos.sale_line, which unconditionally fails since sale_line is append-only-enforced (even for the superuser DB connection, per the prior POS-batch reopen's own trigger, fix #8) — since fixed: apps/api/src/orders/__tests__/orders-schema.spec.ts now uses a tryDelete() helper mirroring pos-schema.spec.ts's own established precedent for this exact append-only-table cleanup problem. (2) A new, previously undisclosed finding: 5 bare (non-composite) FKs remain inside orders itself — order_line.order_id, order_payment.order_id, order_fulfillment.order_idorder_header.id; order_fulfillment_line.order_fulfillment_idorder_fulfillment.id; order_template_line.order_template_idorder_template.id — none of whose parent tables (order_header/order_fulfillment/order_template) currently carry UNIQUE(id, tenant_id). This is the identical cross-tenant-write-exposure bug class this entire remediation effort exists to close, sitting inside Orders' own internals, immediately next to the column this fix just touched — not named anywhere in the design doc or any prior PROJECT_DECISIONS entry. Logged to OPEN_ITEMS.md as a new row, cross-referenced to PROJECT_DECISIONS #50 — a future Orders reopen candidate, not fixed in this pass.
  • Migration: packages/db/migrations/20260710040000_headerline_orders_fix6.sql. Schema file: packages/db/src/schema/orders/line.ts (orderLine gains the sale_line_id column plus the table-level composite foreignKey({...}), named order_line_sale_line_id_tenant_fkey — no .references() on the raw column).
  • Tests: apps/api/src/orders/__tests__/orders-schema.spec.ts, new section M (3 tests: valid same-tenant reference resolves via JOIN; NULL succeeds; cross-tenant reference rejected). Full suite green: 46/46 (up from 43/43 before this fix).

Gap-Fill Batch reopen (2026-07-20) — B10 forfeited deposits

Third reopen of orders, this time as 1 of 5 modules touched by the 2026-07-20 Gap-Fill Batch — an architect-authorized fix pass against 6 confirmed gaps from the 2026-07-19 gap-validation report (vrida-gap-validation-2026-07-19.md), each with a pre-decided shape. orders' own named gap was B10 — forfeited deposits. See PROJECT_DECISIONS #74 for the full record, including the pasted collision-detection discipline and cross-module delta table.

  • orders.order_payment.statuschk_order_payment_status widened in place to add a 7th value, 'forfeited', to the existing (scheduled,due,paid,failed,refunded,cancelled) set. No column added.
  • New function + trigger: orders.guard_order_payment_status() / trg_order_payment_guard_status (BEFORE UPDATE OF status) — a no-op-plus-terminal-reject guard: same-value re-writes tolerated, any transition out of 'forfeited' rejected. Mirrors notifications.delivery_attempt's own monotonic-guard shape (architect-selected — no closer precedent existed on order_header/order_payment themselves). Deliberately scoped to just this one terminal-value rule, not a full ordinal state machine across all 7 statuses (they branch, not chain). See the dedicated section above ("The forfeited-deposit terminal-status guard").
  • Column-count impact: none — 175 columns, 7 tables unchanged. This is a CHECK-widen-plus-trigger fix only.
  • Named open question, deliberately not decided by this fix: whether 'forfeited' still counts toward order_header.balance_due_cents, and whether it posts anywhere in billing — left to OrderService's own future build.
  • Migration: packages/db/migrations/20260720000007_orders_reopen_forfeited_deposits.sql.
  • Tests: apps/api/src/orders/__tests__/orders-schema.spec.ts, +4 new tests (due→forfeited transition; forfeited→refunded transition rejected; same-value no-op tolerated; an unrelated non-forfeited row's normal transition unaffected). Full suite for the file: 51/51 (up from 46/46 before this fix).

Service layer

No OrderService exists yet — schema-only this pass, same pattern as every other module's deferred service layer.

JSONB columns

order_header.attributes (vertical/custom extension point — example shape not yet documented, logged to OPEN_ITEMS per a resolved design decision: deferred to build, real vertical-attribute needs not yet known). order_header.decision_provenance, order_line.decision_provenance, order_payment.decision_provenance, order_fulfillment.decision_provenance, order_template.decision_provenance (all following the codebase-wide decision_provenance convention — reason/evidence/confidence/memory_refs).


Open items carried forward (see OPEN_ITEMS.md for full text)

  1. draft_po_id forward-ref — when Purchasing is designed/built.
  2. stripe_payment_intent_id forward-ref — when Payments is built.
  3. charge_account_ref forward-ref — when Billing is built.
  4. tracking_number/carrier forward-refs — when a Delivery/Shipping module is built.
  5. search_vector — when Search is designed/built in v2.
  6. order_number pg_trgm index — when pg_trgm is enabled.
  7. No OrderService yet — schema-only this pass.
  8. Cross-consumer oversell (order + POS both grab the last unit) — when InventoryService.completeSale()/reserve() implement oversell detection (same trigger as pos's existing row — extend, don't duplicate).
  9. Agent discount/margin ceiling cross-reference — same underlying agent_duty_grant.spend_limit_cents cumulative-tracking gap already logged against identity/pricing/ai.
  10. order_header.attributes JSONB example shape — when real vertical-attribute needs are known.
  11. A potential future Files-module seam (signed quote/contract document) — registered, not built; v1 never had one either.
  12. New 2026-07-10 (Header/Line Remediation reopen, disclosure): 5 bare (non-composite) FKs remain inside orders itself — order_line.order_id/order_payment.order_id/order_fulfillment.order_idorder_header, order_fulfillment_line.order_fulfillment_idorder_fulfillment, order_template_line.order_template_idorder_template — the identical cross-tenant-write-exposure bug class this remediation effort exists to close, found by independent verification of fix #6, not named in the design doc. Trigger: a future Orders reopen — add UNIQUE(id, tenant_id) to order_header/order_fulfillment/order_template first, then upgrade all 5 FKs to composite form.
  13. New 2026-07-20 (Gap-Fill Batch, B10, disclosure): does order_payment.status = 'forfeited' still count toward order_header.balance_due_cents? Does a forfeited deposit post anywhere in billing? Deliberately NOT decided by the B10 fix itself. Trigger: whoever builds OrderService must decide both before a 'forfeited' row is used in any balance or revenue-recognition calculation. See PROJECT_DECISIONS #74.
Last modified: Jul 14, 2026, 1:29 PM PT
On this page
Esc