orders — Module Spec
1. Purpose
orders owns the tenant's commercial-order layer: quotes, orders, special orders, and preorders, with deposit/installment payment schedules and light fulfillment tracking — the third and final module of the sell path (module #15 in the v2 build order), completing crm → pricing → inventory → orders → pos. orders is deliberately narrow: it does not own pricing policy (pricing.price_rule resolves the price; order_line only snapshots it, per Hard Contract 1), it does not own stock truth (inventory.stock_reservation/InventoryService.reserve() own the reservation; orders only links to it), and it does not own the at-register transaction (pos.sale is the fulfillment event an order links to — link-don't-convert, tax finalizes at POS).
This module is the first designed AND built under the Design-Phase Integrity rules (SCHEMA_DESIGN_RUNBOOK.md Section 0.5 / 2.3.6), written the same day specifically to prevent the kind of silent v1→v2 capability loss found after the fact in pos and in the crm/inventory/pricing erosion audit. The full retained delta-accounting record (the 4 mandatory blocks) lives in PROJECT_DECISIONS #29; this spec's own DR-N section (§9) narrates the same material plus this module's specific design calls.
2. Ownership
Owns — 7 tables, 175 columns (up from 173 at 2026-07-07 lock; +1, order_header.entity_id, added 2026-07-08 by Remediation Phase 4 Item 15 — see §13; +1 more, order_line.sale_line_id, added 2026-07-10 by Header/Line Remediation fix #6 — see §14):
| Table | Cols | Role |
|---|---|---|
order_header |
45 | The root commercial-order record — quote, order, special order, or preorder |
order_line |
39 | Line items — Pricing's Hard Contract 1 snapshot fields live here, plus the reservation link and (since 2026-07-10) the line-level fulfillment link |
order_payment |
27 | Deposit/milestone/balance payment schedule rows |
order_fulfillment |
26 | Fulfillment batch header (pickup/delivery/ship/counter handoff) |
order_fulfillment_line |
13 | Per-line, per-batch quantity split and picking outcome |
order_template |
16 | Named recurring order template |
order_template_line |
9 | Default line items for a template |
| Total | 175 |
Does NOT own:
- Pricing policy —
pricing.price_ruleresolves what a variant costs;order_lineonly snapshots the resolved/charged amounts per Pricing's Hard Contract 1 (honored verbatim, see §7).ordersnever derives a price itself. - Stock truth / reservation logic —
inventory.stock_reservation(already anticipatingsource_type='order'before this module existed) is the source of truth for a held quantity;order_line.stock_reservation_idonly links to it.InventoryService.reserve()(not yet built) owns the actual reserve/release logic. - The at-register transaction —
pos.saleis the fulfillment event an order links to viaorder_header.fulfilled_sale_id, not the other way around;pos.saleneeds no reciprocal column (link-don't-convert). Tax finalizes at POS, not here —order_header.estimated_tax_cents/order_line.estimated_line_tax_centsare explicitly display-only. - Card/online payment processing — Payments (not built yet) owns actual processing;
order_paymentonly carries a forward-ref column (stripe_payment_intent_id) for it to fill in later. - Charge-account A/R — Billing (not built yet) owns the actual ledger;
order_payment.charge_account_refis a forward-ref. - Special-order purchasing — Purchasing (not built yet) owns the actual PO;
order_header.draft_po_idis a forward-ref. - Shipping/delivery execution — a future Delivery/Shipping module owns carrier integration;
order_fulfillment.tracking_number/.carrierare forward-refs (v1's own "Module 14" deferral, unchanged).
3. Layer & Dependencies
Tenant-scoped transactional/commercial layer, sitting downstream of CRM, Pricing, Inventory, and POS, and consuming Multi-Location, Shared, and Identity as master/policy data. orders is the third and final module of the sell path — the first two (Pricing, POS) both anticipated it explicitly (Pricing's Hard Contract 1 names orders as its second required consumer; inventory.stock_reservation.source_type and stock_movement.source_module both already included 'order'/'orders' before this module existed).
Depends on:
platform—platform.tenantis the FK target for every tenant-scoped table;platform.set_updated_at()trigger applies to all 7 tables. Since Remediation Phase 4 (2026-07-08), alsoplatform.legal_entity—order_header.entity_id(nullable FK) records which legal entity within the tenant an order belongs to (§13).multi_loc—order_header.site_id,order_fulfillment.site_id(both FK →multi_loc.site, NOT NULL).crm—order_header.customer_id/order_template.customer_id(nullable FK →crm.customer— anonymous quotes fully supported);order_fulfillment.ship_to_address_id(nullable FK →crm.address, required whenfulfillment_type='ship').pricing—order_line.resolving_price_rule_id(nullable FK →pricing.price_rule,ON DELETE RESTRICT) is the traceability pointer; the accompanying snapshot columns honor Pricing's Hard Contract 1 verbatim (§7).inventory—order_line.variant_id/order_fulfillment_line.variant_id/order_template_line.variant_id(FK →inventory.item_variant) is what's being ordered;order_line.stock_reservation_id(nullable FK →inventory.stock_reservation) is the reservation seam (§6, DR-1).pos—order_header.fulfilled_sale_id(nullable FK →pos.sale,ON DELETE RESTRICT) andorder_payment.pos_sale_payment_id(nullable FK →pos.sale_payment) are the fulfillment-link seam (§6, DR-2). Link-don't-convert: the order is never transformed into a sale, it links to the sale created at counter handoff. Since 2026-07-10 (Header/Line Remediation fix #6), alsoorder_line.sale_line_id(nullable, composite FK →pos.sale_line(id, tenant_id)) — the line-level counterpart to the same seam, same link-don't-convert asymmetry (§9, DR-J).shared—order_header/order_line/order_payment.currency_code(FK →shared.currency.iso_code) for global currency validation, no hardcoded default.identity— every*_by_actor_id/*_actor_idcolumn across all 7 tables →identity.actor.id, neveridentity.identity_userdirectly, per the codebase-wide autonomy-first pattern (PROJECT_DECISIONS #19).
Depended on by: Purchasing (not built yet) will fill in order_header.draft_po_id for special orders that trigger a PO. Payments (not built yet) will fill in order_payment.stripe_payment_intent_id. Billing (not built yet) will read order_payment.charge_account_ref. A future Delivery/Shipping module will fill in order_fulfillment.tracking_number/.carrier. Search (not built yet) will add a search_vector column to order_header.
4. Capabilities — honest Part D framing
orders sits between crm's customer-facing judgment calls and pos's overwhelmingly-deterministic recording. There is real agent surface here, concentrated in drafting and flagging, not in committing money or inventory autonomously:
- An agent MAY draft a quote from an inbound email/phone transcript (v1 itself flagged "AI email order parsing" as "Future v2.0" — the seam is designed for now even though no NLP capture pipeline exists yet) —
draft_only. - An agent MAY propose a new
order_templatefrom a detected repeat-order pattern —draft_only. - An agent MAY propose a substitution for a backordered/out-of-stock line, or propose a price override — both
draft_only, and a price override additionally requiresneeds_approvalto actually apply (Part C's C8 — a financial action needing independent approval). - Confirming an order (which commits a real inventory reservation) or cancelling one are human-only when human-initiated, and
needs_approvalif agent-initiated — both are real financial/inventory commitments, not observational actions. - Recording a payment received or progressing fulfillment stages (picking → staged → ready) are
may_act_alone/automation_source='system'— deterministic recording of an already-decided external event, mirroringpos's own sale/payment recording precedent. - An agent MAY flag a fulfillment delay or picking shortfall (
may_act_aloneto flag, observational) — resolving it is human-only, mirroringpos.pos_sync_conflict's "system detects, human resolves" pattern.
No table in this module supports fully autonomous creation of a financial commitment (a confirmed order, a payment, a cancellation) — every one of those routes through needs_approval when agent-initiated. This is the same discipline pos applies to its own transactional tables, just with a real drafting surface (draft_only) upstream of it that pos doesn't have.
No OrderService exists yet — schema-only build (Drizzle + migration + tests). See §5.
5. Service Contract — OrderService
Not built this pass — no OrderService; schema + migration + tests only, matching every other product module's own first-pass precedent (crm, inventory, pricing, pos). Downstream code would query orders.* directly via Drizzle for now.
What IS binding, regardless of when OrderService gets built, are the build requirements in §6 — they constrain the shape OrderService and InventoryService MUST take when they are eventually built, not soft suggestions.
6. Build Requirements (Service-Layer)
The following are binding requirements on whoever builds OrderService/InventoryService, cited DR-N style — not soft deferrals. None is satisfied yet (the consuming service layer doesn't exist); each has a corresponding OPEN_ITEMS row.
DR-1 — reservation happens at confirmation, never at quote
order_line.stock_reservation_id MUST only be populated once an order transitions to status='confirmed' (or later) — never while status IN ('draft','quote_sent'). This preserves v1's own "reserve-on-confirm, not on quote" rule: a quote is a non-binding estimate and must not hold real inventory hostage. InventoryService.reserve() (not yet built) is the actual seam this requirement binds.
DR-2 — fulfilled_sale_id is link-don't-convert, set once, at counter handoff
order_header.fulfilled_sale_id MUST be set exactly once, at the moment a pos.sale is created to fulfill this order (a counter handoff) — the order is never transformed into a sale; it links to the sale that fulfilled it. pos.sale needs no reciprocal column, by design (confirmed against CROSS_MODULE_CONTRACTS.md, the actual seam catalog). Tax finalizes at POS, not here.
DR-3 — cross-consumer oversell (order + POS both grab the last unit) routes through the same conflict mechanism pos already logged
Now that orders is a second writer against inventory.stock/stock_reservation (alongside pos), the same genuine-oversell case pos's own DR-1 names (two different writers independently claim the last unit) applies here too. inventory.pos_sync_conflict's conflict_type enum is generic enough to already represent this without a schema change; the detection logic itself (InventoryService.completeSale()/reserve()) does not exist yet. This is the SAME open item as pos's own DR-1 — extend that implementation to cover orders as a second writer when it's built; do not build or track a duplicate.
DR-4 — money-derivation formulas, documented, not DB-enforced
order_header.estimated_total_cents = subtotal_cents - discount_total_cents + estimated_tax_centsorder_header.balance_due_cents = estimated_total_cents - SUM(order_payment.amount_paid_cents WHERE status='paid')order_line.line_subtotal_cents/line_total_centsderive from the Hard Contract 1 snapshot fields (charged_amount_minor_units * quantity, lessdiscount_amount_cents).
Whoever builds OrderService MUST maintain these as service-computed caches, matching pos.sale/sale_line's own reconciliation-formula precedent (documented, not a DB CHECK — cross-row aggregates aren't CHECK-expressible).
7. 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), snapshotted at the moment of order confirmation:
resolved_amount_minor_units(bigint, NOT NULL) — the pre-rounding resolved amount.charged_amount_minor_units(bigint, NOT NULL) — the actual amount charged after anyPricingServicedisplay-rounding.currency_code(char(3)).tax_treatment(text).resolving_price_rule_id(nullable FK →pricing.price_rule) — NULL when no rule matched and barebase_price_centswas used.resolved_quantity(numeric) — the quantity the price was resolved against, disambiguating whichmin_qtytier fired.
Live-tested (test E1 in the module's test suite): 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, matching pos.sale_line's own field-for-field shape exactly — mark that contract row as SATISFIED by both pos and orders in CROSS_MODULE_CONTRACTS.md.
8. Agent Authority Mapping
orders introduces no new authority mechanism — like every product module since crm, it is a pure consumer of identity.agent_duty_grant (PROJECT_DECISIONS #22). Plausible permission codes: orders:order:draft_quote, orders:order:propose_template, orders:order_line:propose_substitution, orders:order_line:propose_price_override — all draft_only per agent_duty_grant.authority_level, gated by the existing spend_limit_cents/quantity_limit dimensions where relevant, with the already-logged caveat (identity/pricing/ai rows in OPEN_ITEMS) that spend_limit_cents is per-action only, no cumulative tracking — the same cross-module gap, not a new one specific to orders.
An agent proposing a quote, template, substitution, or price override writes an ai.agent_execution row (target_module='orders', target_table='order_header'/'order_line'/'order_template'); a later human approval/rejection is a second agent_execution row linked via resolves_execution_id, mirroring the propose/execute pattern every other module already uses. decision_provenance.memory_refs can point at real ai.agent_memory.id rows — e.g. a memory entry like category='order_pattern', key='<customer_id>:recurring_bulk_order' feeding a template-proposal decision.
9. Design Rationale
DR-A — v1→v2 delta, the retained Design-Phase Integrity record. v1's Orders design carried 7 tables / 133 columns. This build's delta: 7 tables / 173 columns — zero table-count change, +40 columns, zero consolidation (every one of v1's 7 tables survives BUILT). The full 4 mandatory blocks (v1→v2 delta summary, consolidation justification, full table+column fate, dependency-blocked register) are retained verbatim in PROJECT_DECISIONS #29 — this is the permanent v1→v2 record the Design-Phase Integrity rules require, authored before the table list at design time and retained here at build/lock time, not just delivered in a chat proposal and discarded.
DR-B — adversarial design-phase verification caught 5 real issues before any table was built. An independent agent adversarially re-derived the v1→v2 column fate from scratch and found: (1) the cancelled-CHECK silently dropped the actor-attribution requirement (v1 required 3 fields, a first draft only required 2); (2) every table's index list was entirely unaddressed in the first draft, including v1's
order_numberfuzzy-search index; (3)order_payment's two non-negativity CHECKs went unmentioned while a sibling CHECK was carefully disclosed; (4)order_line/order_fulfillment's partial autonomy packs had no stated reasoning against this project's own tier precedent; (5) theconfirmed_at-required CHECK was silently widened beyond v1's literal text without disclosure. All 5 were fixed before the proposal was ever shown for approval — full detail in PROJECT_DECISIONS #29.DR-C —
accepted_bydual-column: preserve both, don't replace.accepted_by(text) is preserved verbatim from v1 — free-form acceptance evidence (a name, an email quote, a verbal-confirmation note). A newaccepted_by_actor_id(FK →identity.actor) was added ALONGSIDE it, not instead of it — a deliberate, explicit decision to track which staff member (or agent) recorded the acceptance, when applicable, without losing v1's free-text expressiveness. Directly enacts Section 0.5's "preserve v1 by default" principle at the column level.DR-D —
confirmed_atCHECK deliberately widened beyond v1's literal text. v1's rule only requiredconfirmed_at IS NOT NULLforstatus='confirmed'literally. This build's CHECK (chk_order_header_confirmed_requires_timestamp) requires it forstatus IN ('confirmed','fulfilling','partially_fulfilled','fulfilled','closed')— every status an order can only reach BY HAVING BEEN confirmed first. This is more correct than v1's own text (an order cannot logically be "fulfilling" without ever having been confirmed), and was flagged as a deliberate widening for review rather than silently presented as an equivalent mechanical promotion — confirmed as the right call.DR-E —
order_line/order_fulfillmentfull autonomy pack, not partial. Both tables carry live judgment/anomaly surface matchingpricing.price_rule/pos.sale's own reasoning for the full 7-column pack (actor attribution,automation_source, full review seam,decision_provenance) —order_lineforprice_override/substitution proposals,order_fulfillmentfor delay/picking-shortfall flagging. This was the adversarial pass's finding 4 (DR-B), independently reconfirmed as the right call rather than a lighter touch.DR-F —
attributesJSONB shape deferred to build, not design.order_header.attributesexists (jsonb, default'{}') as a vertical/custom extension point, but no example shape is documented yet — a deliberate deferral (not an oversight) since real vertical-attribute needs aren't known yet. Logged to OPEN_ITEMS with trigger "when real vertical-attribute needs are known."DR-G — no Files-module seam built. A signed quote/contract document is a plausible future need, but v1 never had one either, and Files doesn't exist in v2. Registered in PROJECT_DECISIONS #29's Block 4 as a potential future dependency, not built this pass.
DR-H — no offline-sync quintet; a single light
idempotency_keyinstead. Part D (D12) explicitly ruled OUTpos's full offline-sync quintet (client_uuid/origin/sync_status/synced_at) — orders is online-first (web/phone/email/counter), never an offline POS terminal. Instead,order_headergets a single nullableidempotency_keywith a two-partial-unique NULL-actor-safe split, matchingpricing.price_rule's own established precedent, notpos's.DR-I — the 3 real (not deferred) seams.
order_line.stock_reservation_id → inventory.stock_reservation,order_header.fulfilled_sale_id → pos.sale, andorder_payment.pos_sale_payment_id → pos.sale_paymentare all built as REAL, enforced FKs — not forward-refs — because all 3 targets exist live. This is a direct consequence ofordersbeing built last among the sell-path modules: what v1 could only document as "will be real once X exists" is now actually real. See §6 (DR-1, DR-2) and PROJECT_DECISIONS #29 for live-verification detail (incl. theON DELETE RESTRICTproof onfulfilled_sale_id).DR-J — Header/Line Remediation reopen (2026-07-10), fix #6 — a 4th real seam, +1 col (174→175).
order_line.sale_line_id(nullable, composite FK →pos.sale_line(id, tenant_id),order_line_sale_line_id_tenant_fkey) extends DR-I's seam set down to the line level — link-don't-convert, matchingorder_header.fulfilled_sale_id's own established asymmetry exactly (no reciprocal column onpos.sale_line's side either). The prerequisiteUNIQUE(id, tenant_id)onpos.sale_linewas laid in the prior batch's POS reopen (fix #8, PROJECT_DECISIONS #46), specifically anticipating this fix. Fully additive: nullable column, zero backfill. Independent verification confirmed the fix fully correct (composite FK genuinely 2-column, all 3 live-reproduction scenarios matching) and disclosed 2 findings, neither invalidating the fix itself: a test-suite cleanup bug (since fixed,tryDelete()now mirrorspos-schema.spec.ts's precedent) and a new, previously undisclosed finding — 5 bare (non-composite) FKs remain insideorders' own internals (order_line.order_id/order_payment.order_id/order_fulfillment.order_id→order_header;order_fulfillment_line.order_fulfillment_id→order_fulfillment;order_template_line.order_template_id→order_template), none of whose parent tables yet carryUNIQUE(id, tenant_id)— logged to OPEN_ITEMS, not fixed this pass (see §10, §14). Migration:packages/db/migrations/20260710040000_headerline_orders_fix6.sql. Live-reproduced and test-confirmed (orders-schema.spec.tssection M, 3 new tests, 46/46 total). See PROJECT_DECISIONS #50.DR-K — Gap-Fill Batch reopen (2026-07-20), B10 — forfeited is a narrow terminal-state guard, not a full status-transition state machine.
order_payment.statusgained a 7th value,'forfeited', but only that one value got a trigger-enforced transition rule (orders.guard_order_payment_status()/trg_order_payment_guard_status, BEFORE UPDATE OF status) — a no-op-plus-terminal-reject shape (same-value re-writes tolerated as idempotent; any transition OUT of'forfeited'rejected), mirroringnotifications.delivery_attempt's own established monotonic-guard shape. This was an explicit architect ruling during design: the task's own wording assumed a precedent trigger already existed on this table to "match," but neitherorder_headernororder_paymentcarried any status-transition trigger beyondset_updated_atbefore this pass —notifications.delivery_attemptwas selected as the closest real analog. The other 6order_paymentstatuses (scheduled/due/paid/failed/refunded/cancelled) deliberately get NO ordinal ranking or transition CHECK: they branch rather than chain (e.g.scheduled/duecan resolve topaid,failed, orcancelled; apaidrow can later becomerefunded), so no single linear sequence exists to encode, and the task itself only asked for one specific rule — forfeited is terminal. Building a full state machine across all 7 values was deliberately out of scope, not an oversight. See §15 and PROJECT_DECISIONS #74.
10. Deferred / Future Items
All items tracked in docs/open-items/OPEN_ITEMS.md, attributed to orders (PROJECT_DECISIONS #29):
| Item | Status | Trigger |
|---|---|---|
order_header.draft_po_id |
deferred | When Purchasing is designed/built → add real FK |
order_payment.stripe_payment_intent_id |
deferred | When Payments module is built |
order_payment.charge_account_ref |
deferred | When Billing module is built |
order_fulfillment.tracking_number/.carrier |
deferred | When a Delivery/Shipping module is designed/built |
order_header.search_vector |
deferred | When Search is designed/built in v2 |
order_header.order_number fuzzy/trgm search |
deferred | When pg_trgm is enabled |
OrderService (service layer) |
open | When OrderService is built |
| DR-1 (reservation-at-confirmation-only) | open, not yet satisfied | InventoryService.reserve() doesn't exist yet |
| DR-3 (cross-consumer oversell, order + POS) | open, not yet satisfied | Same trigger as pos's own DR-1 row — resolve once, extend to cover orders as a second writer |
agent_duty_grant discount/margin-ceiling cross-reference |
open | Same trigger as the existing identity/pricing/ai rows — resolve once, not per-module |
order_header.attributes JSONB example shape |
open | When real vertical-attribute needs are known |
| Files-module seam (signed quote/contract doc) | registered, not built | When Files is designed/built AND a signed-document requirement is confirmed for orders specifically |
5 bare (non-composite) FKs inside orders itself (order_line.order_id/order_payment.order_id/order_fulfillment.order_id → order_header; order_fulfillment_line.order_fulfillment_id → order_fulfillment; order_template_line.order_template_id → order_template) |
open, new 2026-07-10 (Header/Line Remediation reopen, disclosure) | 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. See PROJECT_DECISIONS #50. |
Does order_payment.status = 'forfeited' still count toward order_header.balance_due_cents (§6, DR-4)? Does a forfeited deposit post anywhere in billing? |
open, new 2026-07-20 (Gap-Fill Batch, B10) | Deliberately NOT decided by the B10 fix itself — this pass only added the status value and its terminal-transition guard. Trigger: whoever builds OrderService (§5) must decide both before a 'forfeited' row is used in any balance or revenue-recognition calculation. See §15 and PROJECT_DECISIONS #74. |
11. Cross-Module Seams
Seams are cataloged in docs/modules/CROSS_MODULE_CONTRACTS.md (referenced, not restated here in full). Key relationships:
- orders → multi_loc:
order_header.site_id,order_fulfillment.site_id→multi_loc.site.id(enforced FKs, NOT NULL). - orders → crm:
order_header.customer_id/order_template.customer_id→crm.customer.id(nullable, anonymous quotes supported);order_fulfillment.ship_to_address_id→crm.address.id(nullable, required when shipping). - orders → pricing:
order_line.resolving_price_rule_id→pricing.price_rule.id(enforced FK, nullable,ON DELETE RESTRICT). Pricing's Hard Contract 1 is now SATISFIED by both pos and orders (§7). - orders → inventory:
order_line.variant_id/order_fulfillment_line.variant_id/order_template_line.variant_id→inventory.item_variant.id(enforced FKs).order_line.stock_reservation_id→inventory.stock_reservation.id(enforced FK, nullable) — the reservation seam (§6, DR-1). - orders → pos:
order_header.fulfilled_sale_id→pos.sale.id(enforced FK,ON DELETE RESTRICT);order_payment.pos_sale_payment_id→pos.sale_payment.id(enforced FK). Link-don't-convert;pos.saleneeds no reciprocal column (§6, DR-2). New (2026-07-10, Header/Line Remediation fix #6):order_line.sale_line_id→pos.sale_line.id(enforced, composite FK, nullable) — the line-level counterpart, same link-don't-convert asymmetry (§9, DR-J). - orders → shared:
order_header/order_line/order_payment.currency_code→shared.currency.iso_code(enforced FKs). - orders → identity: every
*_by_actor_id/*_actor_idcolumn →identity.actor.id(enforced FKs). No new authority mechanism — pure consumer ofidentity.agent_duty_grant(§8). - orders → platform (new, Remediation Phase 4):
order_header.entity_id→platform.legal_entity.id(enforced FK, nullable) — which legal entity within the tenant an order belongs to, when the tenant operates multiple LLCs (§13). - Purchasing → orders (forward-ref, Purchasing not built yet):
order_header.draft_po_id— plain UUID today, FK wired when Purchasing exists. - Payments → orders (forward-ref, Payments not built yet):
order_payment.stripe_payment_intent_id. - Billing → orders (forward-ref, Billing not built yet):
order_payment.charge_account_ref— mirrorspos.sale_payment.charge_account_ref's identical treatment. - Delivery/Shipping → orders (forward-ref, module not built yet):
order_fulfillment.tracking_number/.carrier.
12. v1 Exclusions Re-Confirmed
None. Unlike every other product module built so far, orders has zero v1 tables excluded, deferred, or consolidated — all 7 of v1's tables survive BUILT (PROJECT_DECISIONS #29, Block 3). The only exclusions are column-level forward-refs into modules that don't exist yet (§10), which is a different category from a table-level exclusion — nothing about orders' own design left a v1 capability unaddressed.
13. Remediation Phase 4 (2026-07-08)
orders was reopened as 1 of 11 modules touched by Remediation Plan Phase 4, the final phase of the 4-phase remediation plan (see PROJECT_DECISIONS #40). The single change against this module was Item 15 — Legal Entity: platform.legal_entity was introduced (1:N from platform.tenant, so a tenant can incorporate a 2nd LLC without splitting into two tenants), and a nullable entity_id FK column was added to 10 header tables across 8 modules independently derived for this rollout — orders.order_header is one of them, alongside platform.contract, platform.billing_account, admin.compliance_document, tax.tax_calculation, billing.ar_account, billing.vendor_payable, purchasing.vendor_invoice, purchasing.purchase_order, and pos.sale.
Change: order_header.entity_id (UUID, nullable, FK → platform.legal_entity.id) — which legal entity within the tenant this order belongs to, when the tenant operates multiple LLCs; NULL means the tenant's primary entity. Pure additive: table count unchanged at 7, column count 173 → 174 (order_header 44 → 45). 0 live order_header rows required backfill at build time — zero backfill risk.
Scoping rule (disclosed this same phase): entity_id was added ONLY to order_header (the header table), never to any of orders' 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. This mirrors the same rule applied to the other 9 tables in this rollout (e.g. purchasing.purchase_order gets it, purchase_order_line does not).
Migration: packages/db/migrations/20260709010000_phase4_item15_legal_entity.sql. Test coverage: orders-schema.spec.ts section L (+2 tests — order_header.entity_id FK resolution + nullability), 43/43 total.
LIVE-REPRODUCED (per PROJECT_DECISIONS #40, Item 15): order_header.entity_id confirmed nullable and FK-valid against platform.legal_entity, consistent with the other 9 header tables touched by this rollout. Independently verified by 2 adversarial lenses, both CLEAN.
No other Phase 4 item (14, 16–20) touched orders — those items' tables and columns live in platform, pos, tax, shared, admin, crm, ai, and inventory.
14. Header/Line Remediation reopen (2026-07-10) — fix #6
orders was reopened as the second module of the second batch of the coordinated "Header/Line Remediation" effort (vrida-header-line-remediation-design-2026-07-10.md) — 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 named in the design doc's §6a (the prerequisite pos.sale_line UNIQUE(id, tenant_id) was laid in the first batch's own POS reopen, fix #8, PROJECT_DECISIONS #46). See PROJECT_DECISIONS #50 for the full record, including the pasted, attributed independent-verification summary, and §9 DR-J above for the design rationale.
Change: order_line.sale_line_id (UUID, nullable, composite FK → pos.sale_line(id, tenant_id), named order_line_sale_line_id_tenant_fkey) — line-level fulfillment linkage, link-don't-convert, matching order_header.fulfilled_sale_id's own established asymmetry (no reciprocal column on pos.sale_line's side either). Pure additive: table count unchanged at 7, column count 174 → 175 (order_line 38 → 39). 0 backfill — nullable, no existing order_line rows touched.
Independent verification, pasted and attributed. A separate, adversarial agent confirmed the core fix fully correct against a fresh transaction: the composite FK is genuinely 2-column (pg_constraint-verified), the prerequisite UNIQUE(id, tenant_id) on pos.sale_line is present, and all 3 live-reproduction scenarios (valid same-tenant reference, NULL, cross-tenant reference rejected) reproduced with matching error text. 2 findings disclosed, neither invalidating fix #6 itself:
- A test-suite bug caught DURING verification — the new test section's
afterAlloriginally attempted a hardDELETE FROM pos.sale_line, which unconditionally fails sincesale_lineis append-only-enforced (even for the superuser connection, per the prior POS-batch reopen's own trigger). Since fixed:apps/api/src/orders/__tests__/orders-schema.spec.tsnow uses atryDelete()helper mirroringpos-schema.spec.ts's own established precedent for this exact cleanup problem. - A new, previously undisclosed finding: 5 bare (non-composite) FKs remain inside
orders' own internals —order_line.order_id,order_payment.order_id,order_fulfillment.order_id→order_header.id;order_fulfillment_line.order_fulfillment_id→order_fulfillment.id;order_template_line.order_template_id→order_template.id— none of whose parent tables currently carryUNIQUE(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, and not named anywhere in the design doc or any prior PROJECT_DECISIONS entry. Logged toOPEN_ITEMS.md(§10 above), 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. Test coverage: orders-schema.spec.ts new section M (3 tests), full suite 46/46 (up from 43/43 before this fix).
15. Gap-Fill Batch reopen (2026-07-20) — B10 forfeited deposits
orders was reopened as 1 of 5 modules touched by the 2026-07-20 Gap-Fill Batch (6 confirmed 2026-07-19 gap-validation findings — vrida-gap-validation-2026-07-19.md, PROJECT_DECISIONS #74) for its own named gap, B10 — forfeited deposits. Before this fix, order_payment.status had no value meaning "the tenant kept the money, but the underlying order did not proceed" — a customer-forfeited special-order deposit (order cancelled by the customer, tenant keeps the deposit per stated policy) had to be misrecorded as either 'cancelled' (which literally means no money changed hands) or 'refunded' (which literally means the money went back).
Capability added: order_payment.status can now be 'forfeited' — a customer-forfeited deposit is now representable distinctly from both 'cancelled' and 'refunded'.
Change: chk_order_payment_status widened to add 'forfeited' to the existing 6-value set (scheduled,due,paid,failed,refunded,cancelled). New function orders.guard_order_payment_status() + trigger trg_order_payment_guard_status (BEFORE UPDATE OF status): a no-op-plus-terminal-reject shape — same-value re-writes are tolerated (idempotent), but any transition OUT of 'forfeited' (the sole terminal value this pass cares about) is rejected. This mirrors notifications.delivery_attempt's own established monotonic guard, architect-selected since neither order_header nor order_payment carried any prior status-transition trigger to "match precedent" against (see §9, DR-K). Deliberately NOT a full ordinal ranking across the other 6, non-linear statuses. Pure CHECK + trigger change — no column added; order_payment stays 27 cols, orders stays 7 tables / 175 cols.
Named open question — deliberately NOT decided by this pass: whether a 'forfeited' payment still counts toward order_header.balance_due_cents (§6, DR-4's derivation formula), and whether a forfeited deposit posts anywhere in billing (once BillingService/A/R exist), are both left open. This fix only adds the status value and its terminal-transition guard — it does not touch balance_due_cents's computation and wires no billing seam. Whoever builds OrderService (§5) must decide both before a 'forfeited' row is used in any balance or revenue-recognition calculation. See §10.
Migration: packages/db/migrations/20260720000007_orders_reopen_forfeited_deposits.sql. Test coverage: orders-schema.spec.ts +4 tests (due→forfeited transition; forfeited→refunded transition rejected; same-value no-op tolerated; an unrelated non-forfeited row's normal transition unaffected), 51/51 for the file. See PROJECT_DECISIONS #74.