pos — Module Spec
1. Purpose
pos owns the tenant's point-of-sale transaction layer: registers, cash-drawer sessions, sales, sale lines, tenders, and refunds, for a physical retail checkout — the second module of the sell path (module #14 in the v2 build order), immediately downstream of pricing (module #13). pos is deliberately narrow: it does not own pricing policy (pricing.price_rule resolves the price; pos.sale_line only snapshots it), it does not own stock truth (inventory.stock/InventoryService.completeSale() own the decrement; pos only calls it), and it does not own online/fulfillment orders (orders, not yet built, links back to the pos.sale that fulfilled it — link-don't-convert, tax finalizes at POS).
This module's defining engineering property is offline-first: a register may lose connectivity mid-shift and must keep selling. sale, sale_payment, and sale_refund all carry a device-generated client_uuid, an origin (online/offline), and a sync_status (synced/local_only/conflict), with the write path built around INSERT ... ON CONFLICT (tenant_id, client_uuid) DO NOTHING rather than server-generated identity. This is the same offline-sync shape the Dart/Flutter POS client requires, and it is why this module's schema work is dominated by dedup/conflict machinery rather than by transactional modeling novelty.
2. Ownership
Owns — 12 tables, 183 columns (up from 10 tables / 160 cols, +2 tables / +23 columns — Gap-Fill Batch, 2026-07-20, see §10 DR-O and PROJECT_DECISIONS #74); previously up from 9 tables / 143 cols, +1 table / +17 columns — Remediation Phase 4, 2026-07-08, see §10 DR-K; reopened 2026-07-10 for Header/Line Remediation fix #8 (a bug fix + 2 bundled schema additions — table/column counts unchanged, see §10 DR-L):
| Table | Cols | Role |
|---|---|---|
register |
8 | A physical or virtual checkout terminal at a site |
register_session |
18 | One cash-drawer shift: open → close, opening/closing float |
register_cash_entry |
15 | Mid-session cash movements (paid in/out, drops); gained business_date + its first-ever full review seam in Remediation Phase 4 |
sale |
27 | The transaction header — immutable-gross total, status, offline-sync columns, sale_number, business_date, entity_id |
sale_line |
15 | Line items — Pricing's Hard Contract 1 snapshot fields live here; gained is_gift (line-level gift-receipt intent flag) in the Gap-Fill Batch, 2026-07-20 — see §10 DR-O |
sale_payment |
27 | Tenders against a sale — offline-sync columns, forward-refs to Payments, tender_type_id |
sale_refund |
24 | A refund event against a sale — offline-sync columns, tax-refund capture, business_date; sale_id now nullable (anonymous walk-in returns, Remediation Phase 4) |
sale_refund_line |
10 | Which sale lines / quantities a refund covers, or a no-receipt item_variant_id |
pos_sync_conflict |
10 | Records an offline-sync conflict (e.g. stock-oversell) for human resolution |
tender_type_catalog |
7 | NEW, Remediation Phase 4 — global reference catalog of tender types, additive-interim alongside sale_payment.payment_method's CHECK-enum |
parked_cart |
13 | NEW, Gap-Fill Batch (2026-07-20) — a held register cart, paused and later resumed into a real sale or discarded; pure UI/workflow convenience, never an inventory hold — see §10 DR-O |
parked_cart_line |
9 | NEW, Gap-Fill Batch (2026-07-20) — line items of a parked cart; unit_price_snapshot is advisory only, re-priced for real by PosService at resume time |
| Total | 183 |
Does NOT own:
- Pricing policy —
pricing.price_ruleresolves what a variant costs;pos.sale_lineonly snapshots the resolved/charged amounts per Pricing's Hard Contract 1 (honored verbatim, see §7).posnever derives a price itself. - Stock truth / decrement logic —
inventory.stock.available_qty(an existing generated column nettingon_hand_qty - reserved_qty) is the source of truth. A completed sale callsInventoryService.completeSale()directly; no POS-side reservation table exists (stock_reservation.source_type's CHECK does not include'pos'— confirmed live — reservations stay Orders' own mechanism). - Online/fulfillment orders —
orders(not built yet) owns the order lifecycle;pos.saleis the fulfillment event an order links to, not the other way around.pos.saleneeds no reciprocal column for this. - Card/Terminal payment processing — Payments (not built yet) owns the actual Stripe Terminal integration;
sale_paymentonly carries forward-ref columns (stripe_payment_intent_id,charge_account_ref) for it to fill in later. - Tax computation/breakdown —
sale_line.tax_treatmentstates whether the snapshotted amount is tax-inclusive/-exclusive (Pricing's Hard Contract 1 field); full multi-jurisdiction tax breakdown issale_line_tax, deferred this pass (see §13). - Gift cards, store credit, layaway, saved-cart templates, guarantees — all deferred this pass; see §13's 10 deferred v1 tables.
sale_template/sale_template_line(reusable/recurring cart definitions — e.g. a standing weekly order) remain deferred; this is a distinct concept fromparked_cart(a transient hold-and-resume mechanism for an in-progress register transaction, built 2026-07-20 — see §10 DR-O), which is now owned, not deferred. - Loyalty/rewards and offers/coupons — Rewards' and Offers' own domains (neither built in v2 yet);
pos.salecarries no columns for either. See §13's open questions.
3. Layer & Dependencies
Tenant-scoped transactional layer, sitting immediately downstream of Pricing and consuming Inventory, Multi-Location, CRM, Shared, and Identity as master/policy data. pos is the first of the not-yet-built transactional modules Pricing's design pass anticipated (POS/Orders) — this build is the first to actually exist and therefore the first to make Pricing's Hard Contract 1 real.
Depends on:
platform—platform.tenantis the FK target for every tenant-scoped table;platform.set_updated_at()trigger applies toregister,register_session,sale,sale_payment,sale_refund, and (Remediation Phase 4)tender_type_catalog(6 tables — notregister_cash_entry,sale_line,sale_refund_line,pos_sync_conflict, which are append-only/child rows). New (Remediation Phase 4):sale.entity_id→platform.legal_entity.id(nullable);platform.flag_closed_period_business_date()— a shared trigger function owned byplatform, consumed bysale/sale_refund/register_cash_entryto flag (never reject) abusiness_datefalling inside a closedplatform.accounting_period.multi_loc—register.site_idandsale.site_id(both FK →multi_loc.site, NOT NULL, ON DELETE RESTRICT) — every register and every sale belongs to exactly one site.inventory—sale_line.item_variant_id(FK →inventory.item_variant, ON DELETE RESTRICT) is what's being sold;InventoryService.completeSale()is called at sale completion for the stock decrement (service-layer seam, not a schema FK).inventory.stock.available_qtyis read (not written) byposto confirm sellability.pricing—sale_line.resolving_price_rule_id(nullable FK →pricing.price_rule, ON DELETE RESTRICT — NULL when no rule matched and barebase_price_centswas used) is the traceability pointer; the accompanying snapshot columns honor Pricing's Hard Contract 1 verbatim (§7).crm—sale.customer_id(nullable FK →crm.customer, ON DELETE RESTRICT) — anonymous sales are fully supported; a customer is linked when present.shared—sale/sale_line/sale_payment.currency_code(FK →shared.currency.iso_code, ON DELETE RESTRICT) for global currency validation.identity— every*_by_actor_idcolumn across the tenant-scoped tables →identity.actor.id, neveridentity.identity_userdirectly, per the codebase-wide autonomy-first pattern (PROJECT_DECISIONS #19).tender_type_catalog(Remediation Phase 4) is global reference data and carries no actor-attribution columns.
Depended on by: orders (not built yet) via order_header.fulfilled_sale_id → pos.sale — link-don't-convert; tax finalizes at POS (see §14). Payments (not built yet) will fill in sale_payment.stripe_payment_intent_id after processing a Terminal charge. Billing (not built yet) will read sale_payment.charge_account_ref to post an A/R entry for a charge-account tender.
4. Capabilities — honest Part D framing
pos is overwhelmingly human-initiated, system-recorded. A cashier rings a sale; the register (hardware or the Flutter app) records it. There is no meaningful sense in which an agent "creates a transaction" in this module, and this build does not introduce one. This is a deliberate, load-bearing framing choice, not an oversight — POS is a financial point-of-capture, not a decision-support surface like Pricing's markdown loop.
The only autonomy touch this module has is agent-flagged-anomaly review, and even that is narrow:
- An agent (or a scheduled job under
automation_source) MAY be the one that detects an offline-sync anomaly (e.g. the stock-oversell case in DR-1 below) and writes the resultingpos_sync_conflictrow. This is system-recorded observation, not transaction creation — the underlyingsale/sale_paymentrows the conflict refers to were already created (by a human cashier, possibly offline) before any agent touches anything. - A human then reviews and resolves the conflict (e.g. approves a manual stock correction, voids the oversold line). This resolution step is itself human action, not a second autonomous write.
- No table in this module supports
authority_level='may_act_alone'for creating asale,sale_payment, orsale_refundrow. Autonomous creation of a financial transaction is out of scope structurally, not just by policy — there is no proposal/draft state on these tables the waypricing.price_rulehasreview_status='pending', because there is nothing to propose: a sale is either rung by a human at a register or it doesn't exist.
Corrected here (2026-07-07, post-build verification found this section had drifted from the live schema — see below): register_session, sale, sale_payment, and sale_refund DO carry the full review_status/review_reason/reviewed_by_actor_id/reviewed_at/decision_provenance autonomy column set (verified live) — the seam this exists for is exactly the agent-flagged-anomaly review case above (a large discount, a void pattern, a cash variance at close), never for gating a sale's own creation. There is no draft-then-approve workflow for a rung sale itself (a completed sale is never "pending" the way pricing.price_rule can be) — the review seam applies to flagging something about an already-completed row, not to creating it. pos_sync_conflict, by contrast, gets its own dedicated seam (status/resolved_by_actor_id/resolution_note/resolved_at) and deliberately has no automation_source column at all — a conflict is always system-detected, never agent- or human-authored, so there is nothing for automation_source to record.
Updated 2026-07-08 (Remediation Phase 4, DR-K, Item 14): register_cash_entry now also carries the full review seam (review_status/review_reason/reviewed_by_actor_id/reviewed_at/decision_provenance) — a necessary mid-build discovery, not a planned extension of this section's own framing. The trigger that needed it is system-only (platform.flag_closed_period_business_date(), flagging a closed-period business_date), not an agent-authored review — the seam's shape is reused, but nothing here changes this module's core position that no table supports autonomous creation of a financial transaction.
No POSService exists yet — schema-only build (Drizzle + migration + tests). See §6.
5. Service Contract — POSService
Not built this pass — no POSService; schema + migration + tests only, matching shared/multi_loc/crm/inventory/ai/pricing's own precedent for a module's first pass. Downstream code would query pos.* directly via Drizzle for now; since orders and Payments (the other live callers this module's seams anticipate) also don't exist yet, there is no live caller at all today beyond the schema and its tests.
What IS binding, regardless of when POSService gets built, are the build requirements in §6 — they constrain the shape POSService, InventoryService, and the refund-issuance path MUST take when they are eventually built, not soft suggestions.
6. Build Requirements (Service-Layer)
The following are binding requirements on whoever builds POSService/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 — InventoryService.completeSale() MUST convert a would-be-negative stock into a pos_sync_conflict row, never a hard fail or negative stock
Offline-first POS creates two distinct problems, and this requirement addresses only the second:
- Duplicate-sale replay (same logical sale, same
client_uuid, arrives twice from the same device after reconnecting) — solved entirely by theUNIQUE (tenant_id, client_uuid) WHERE deleted_at IS NULLdedup index onsale/sale_payment/sale_refund, viaINSERT ... ON CONFLICT (tenant_id, client_uuid) DO NOTHING. No conflict row is ever created for this case — it is a pure no-op on retry. - Genuine two-different-sale oversell (two DIFFERENT offline devices, two DIFFERENT
client_uuids, each independently sells the last unit of the same variant while both are offline) — NOT caught by #1's dedup at all, since both rows have distinct keys and both insert successfully. This is the case this requirement addresses.
Requirement: at sync-processing time, InventoryService.completeSale() MUST detect that applying a given sale's stock decrement would push inventory.stock.available_qty negative, and instead of (a) allowing negative stock, or (b) hard-failing the sync (which would strand a legitimately-completed offline sale), it MUST create a pos.pos_sync_conflict row with conflict_type='stock_oversell', leaving the sale itself intact for human resolution.
This is a documented service-layer build requirement — InventoryService does not exist yet, so this detection logic is NOT built or testable yet. Only the schema's ability to represent the resulting conflict row is built and tested this pass (test B3 in the module's test suite).
DR-2 — the refund-issuance path MUST enforce refunds never exceed payments, at the service layer
sale.total_minor_units is immutable-gross forever (live-tested, test D1: inserting a sale_refund row does not change it), and sale.status's CHECK deliberately has no 'refunded' value (('open','completed','voided') only) — net refunded position is a query-time computation, not a stored state. This makes the refund-issuance path itself the only enforcement point.
Requirement: whoever builds the refund-issuance path MUST verify, before committing a new sale_refund row, that sum(sale_refund.refunded_amount_minor_units) for a given sale — including the new row being issued — never exceeds sum(sale_payment.charged_amount_minor_units) for that same sale. This is not DB-CHECK-enforceable (it is a cross-row, cross-table aggregate comparison); it is documented here as binding service-layer discipline, the same class of requirement as Pricing's Hard Contracts.
DR-3 — idempotency_key MUST be deterministically derived from client_uuid
sale, sale_payment, and sale_refund each carry a nullable idempotency_key (text). It carries no unique constraint of its own within pos — it is a deliberate pass-through value, not an oversight: the actual dedup guarantee within pos is the (tenant_id, client_uuid) unique index, and idempotency_key's job is solely to be forwarded unchanged to inventory.stock_movement.idempotency_key, which enforces its own, separately-scoped unique index on the Inventory side.
Requirement: idempotency_key MUST be deterministically derived from client_uuid (e.g. a stable hash or direct pass-through of the same value) and passed unchanged to inventory.stock_movement.idempotency_key on every retry — including sync replay after an offline period. This is a documented build requirement, not DB-enforced; nothing in the schema stops a caller from generating a fresh, non-deterministic key per attempt, which would defeat Inventory's own dedup on retried stock movements.
7. Pricing's Hard Contract 1 — honored verbatim on sale_line
sale_line carries all 6 fields Pricing's Hard Contract 1 requires, snapshotted at the moment of sale 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 C1): after the resolving price_rule row is superseded to a new price (Pricing's supersede-don't-edit mechanism, DR-5 of pricing.md), sale_line's snapshot amounts remain unchanged — the sale line is the historical record of what the customer actually paid, never re-derived from the live rule. This satisfies Hard Contract 1 from the pos side; mark that contract row as SATISFIED by pos in CROSS_MODULE_CONTRACTS.md, still pending on orders' own side (orders not built yet).
8. The Offline-Sync Model
The 4 offline-sync columns (on sale, sale_payment, sale_refund)
client_uuid(uuid, NOT NULL, device-generated) — the identity the dedup mechanism keys on.origin(text, default'online', CHECK IN'online'/'offline') — was this row created while the device had connectivity.sync_status(text, default'synced', CHECK IN'synced'/'local_only'/'conflict') — where this row is in the sync lifecycle.idempotency_key(text, nullable) — see DR-3.synced_at(timestamptz, nullable) — when this row was confirmed synced to the server.
Write path: INSERT ... ON CONFLICT (tenant_id, client_uuid) DO NOTHING — a replayed row from a reconnecting device is a silent no-op, not an error.
New CHECK, one per table: chk_<table>_origin_sync_status_consistency — an online-created row must have sync_status='synced'; an offline-created row may be local_only/conflict/synced (it starts local_only, and becomes synced once the sync pipeline confirms it, or conflict if DR-1's oversell case fires).
NULL-distinctness class, explicitly checked: client_uuid is NOT NULL on all 3 tables, so the (tenant_id, client_uuid) unique needs no COALESCE/two-partial-index split — unlike pricing.price_rule's nullable-actor-dimension fix (DR-15 of pricing.md). This was verified explicitly during this module's Section 4 audit, not assumed by analogy.
pos_sync_conflict
Represents an offline-sync conflict requiring human resolution. conflict_type='stock_oversell' is the type DR-1 addresses; the table is generic enough to carry other conflict types the sync pipeline may surface later, without a schema change. This is a schema-first build: the table can represent a conflict row today; the detection logic that would populate it (InventoryService.completeSale(), DR-1) does not exist yet.
9. Agent Authority Mapping
pos introduces no new authority mechanism — like crm, inventory, ai, and pricing before it, it is a pure consumer of identity.agent_duty_grant (PROJECT_DECISIONS #22). But the mapping here is narrower than any prior module's, matching §4's honest framing:
- No
agent_duty_grantscope exists for creating asale,sale_payment, orsale_refund. These are always human-initiated, system-recorded — there is noauthority_level(draft_only or may_act_alone) that applies to "an agent rings a sale," because that action does not exist in this module's design. - The narrow exception is
pos_sync_conflictcreation: an agent/scheduled job flagging a detected anomaly is a plausiblemay_act_aloneaction (observational — it records that a conflict exists; it does not resolve it, move money, or adjust stock), directly analogous toai.agent_execution's own logging precedent. Corrected here (2026-07-07, post-build verification):pos_sync_conflictgets its own dedicated review seam (status/resolved_by_actor_id/resolution_note/resolved_at) but deliberately carries noautomation_sourcecolumn — a conflict is always system-detected, never agent- or human-authored, so there is nothing for that column to record. This is the opposite of what an earlier draft of this section claimed; the fullreview_status/decision_provenanceset is NOT omitted from the transactional tables —register_session,sale,sale_payment, andsale_refundall carry it (verified live), for the agent-flagged-anomaly-review case described in §4. - Resolving a
pos_sync_conflict(approving a stock correction, voiding an oversold line) is human action, captured wherever the resolution actually lands (e.g. aninventory.stock_movementcorrection row, or asale_linevoid) — not a second autonomous write on the conflict row itself.
10. Design Rationale
DR-A — v1 reconciliation. v1's POS design carried 19 tables / 280 columns. This build's delta: 9 tables / 138 columns, a deliberate reduction, not an oversight — 10 v1 tables are explicitly deferred (§13) rather than silently dropped, each with its own trigger condition. The core transactional shape (register → session → sale → sale_line → payment/refund) is unchanged from v1's governing model; what changed is (1) full retargeting of actor columns to
identity.actorinstead ofidentity.identity_user, (2) the addition of the offline-sync column set (client_uuid/origin/sync_status/idempotency_key/synced_at) acrosssale/sale_payment/sale_refund, which v1 did not have in this explicit a form, (3)pos_sync_conflict, a wholly new table with no v1 precedent, and (4) Pricing's Hard Contract 1 snapshot fields onsale_line, which v1 predates entirely (pricingdid not exist as a locked module when v1's POS design was drafted).DR-B — the two-distinct-offline-problems framing (dedup vs. oversell), and why only one gets a conflict table. Conflating "duplicate replay" and "genuine oversell" into one conflict-handling story would either over-flag (creating a conflict row for every harmless retry) or under-flag (silently allowing negative stock on genuine oversell). Keeping them structurally separate — the
client_uuidunique index handles #1 with zero conflict-row overhead;pos_sync_conflict+ DR-1 handles #2 exclusively — means the common case (a device reconnects and replays its own queue) never touches the conflict table at all, and the conflict table's volume stays proportional to actual cross-device contention, not sync-retry noise.DR-C — the immutable-gross-total model, and why
sale.statusdeliberately has no'refunded'value. Adding a'refunded'status value would create two sources of truth for "how much of this sale is refunded" — the status enum and thesale_refundrows themselves — which could drift (e.g. a partial refund leaving the status ambiguous between "refunded" and "completed"). Keepingsale.total_minor_unitsimmutable-gross (live-tested, test D1) and computing net-refunded position assum(sale_refund.refunded_amount_minor_units)at query time means there is exactly one source of truth, at the cost of pushing the DR-2 enforcement requirement to the service layer rather than a DB CHECK.DR-D — no POS-side stock reservation table.
inventory.stock_reservation.source_type's CHECK does not include'pos'(confirmed live) — reservations are Orders' own mechanism for held/pending fulfillment, a concept that doesn't apply to an at-register sale that completes atomically. A POS sale callsInventoryService.completeSale()directly rather than reserve-then-fulfill, so nopos-side reservation table was built;inventory.stock.available_qty's existing generated-column netting is sufficient and already free.DR-E — the dropped
sale.search_vectorcolumn. The PROPOSE+STOP design draft listed a generatedtsvector/GINsearch_vectorcolumn onsale, matching the precedent applied toitem/item_variant/customer. This was DROPPED during build: unlike those 3 tables,salehas no free-text field (name/description/SKU) to feed it — the only candidate wasstatus, a 3-value enum, which would produce a near-useless index. Logged to OPEN_ITEMS as a deferred add-later item (e.g. if a futurejob_reference/po_number-style free-text field is added tosale), not silently dropped from the design.DR-F —
sale.register_session_idis immutable after insert, patched same-day.trg_pos_sale_requires_open_sessionoriginally validatedregister_session_idatINSERTonly. Independent Section 4 verification (2026-07-07) live-reproduced a real gap: after a sale was inserted against an open session, closing that session and thenUPDATE pos.sale SET register_session_id = <a different, now-closed session>succeeded silently, with zero DB-level protection. The correct fix is not "the new session must still be open" (that would still permit silently reassigning a completed sale to a different open session, which is equally wrong) — it's that this column is immutable once set, matching the design's own stated intent that a sale permanently belongs to whichever session was open when it was created. Patched viapackages/db/migrations/20260707010000_pos_sale_session_immutable.sql, extending the trigger toBEFORE INSERT OR UPDATE OF register_session_id, rejecting any UPDATE that changes the value. Live-tested (test E3).DR-G —
sale_paymentfail-closed gate ongift_card/store_credit, added 2026-07-07 (reopens the lock for a live bug, not a design revisit). The 19-to-9 functionality audit found a real, live gap:chk_sale_payment_payment_methodalready permitspayment_method IN ('gift_card','store_credit'), andgift_card_id/store_credit_idexist as forward-ref columns — but neither stored-value subsystem exists yet, and those columns have no FK target, so a tender tagged as either was silently accepted with zero balance validation. A cashier could apply $500 of "gift_card" tender against a card with $0 left, expired, or nonexistent, and nothing signaled a problem. Two candidate fixes were weighed: (a) requiregift_card_id/store_credit_idNOT NULLwhen those methods are used, or (b) reject the two tender types outright until the subsystem exists. (a) was REJECTED — there is still no table to validate the referenced uuid against, so a NOT NULL requirement only forces SOME value to be present, not a REAL one; it would look like validation without providing any. (b) was chosen:chk_sale_payment_no_unvalidated_stored_value_tender(CHECK (payment_method NOT IN ('gift_card','store_credit'))), fail-closed, coexisting with the unchanged base enum CHECK (a row must satisfy both — the enum itself still declares all 7 valid tender-tagging values, only this second, deliberately TEMPORARY CHECK narrows what's currently insertable). Migration:packages/db/migrations/20260707020000_pos_gift_card_store_credit_gate.sql. Live-tested (tests B5a/B5b/B5c): unvalidatedgift_card/store_credittenders rejected; every other tender type unaffected. Drop or loosen this CHECK when the stored-value module ships — tracked in OPEN_ITEMS as its own row, not a permanent restriction.Companion fix, same pass, found by independent Section 4 verification (not self-graded). The verifier judged the two-candidate-fix reasoning above sound but incomplete: it never evaluated whether a NON-stored-value tender could still carry a stored-value reference. Live-reproduced:
payment_method='cash'with a fabricatedgift_card_idinserted with zero complaint — the identical silent-acceptance bug class this whole reopen exists to close, just relocated to the forward-ref columns instead of the enum. Addedchk_sale_payment_stored_value_ref_matches_method(CHECK (payment_method IN ('gift_card','store_credit') OR (gift_card_id IS NULL AND store_credit_id IS NULL))) to the same migration, same day. Live-tested (test B5d). The OPEN_ITEMS trigger for both CHECKs now explicitly requires the eventual replacement to add real FKs, not just drop the CHECKs — otherwise this exact gap reopens when the stored-value module ships.DR-H — column/enum-level erosions found by the same audit, logged not rebuilt. The 19-to-9 audit also found that several v1 tables surviving as BUILT v2 tables did not survive with every column:
salelost cart hold/resume (held_at/hold_expires_at), B2B/contractor fields (po_number/job_reference/delivery_date/pickup_window), and the loyalty-points receipt snapshot;sale_linelost its comp/sample/replacementline_typedistinction and manual price-override tracking;registerlost hardware-pairing config;register_cash_entry.entry_typenarrowed from 6 values to 3. None of these block the module's core sale/payment/refund/sync functions today — per the task's own explicit instruction, these are LOGGED to OPEN_ITEMS with concrete triggers (see §11), not rebuilt this pass; restoring any of them is a future, per-feature decision. This is also the finding that motivated extendingSCHEMA_DESIGN_RUNBOOK.md's standing OPEN_ITEMS rule (Section 6 item 12 / Recurring Bug Class #11) to column/enum granularity, not just table granularity — a table reading as "BUILT" is not evidence every one of its v1 columns survived with it.DR-I — Remediation Phase 2 PK-generation-strategy change on the 3 append-only ledgers (2026-07-08).
register_cash_entry.id,sale_line.id, andsale_refund_line.idhad theirDEFAULTchanged fromgen_random_uuid()toplatform.uuid_generate_v7()(Remediation Phase 2, Item 6;sale_lineis 1 of the plan's 4 named "hot ledgers"). UUIDv7 is time-ordered, keeping future time-range partitioning possible on these append-only ledgers without a PK rewrite — impossible once data lands on a random UUIDv4 PK.pos_sync_conflictwas considered but explicitly EXCLUDED: itsstatuscolumn is an explicit mutableopen/resolved/ignoredhuman-in-the-loop workflow field per its own Drizzle source comment, not append-only. DEFAULT-only — no column/table count change. Correction, 2026-07-10 (DR-L): this entry's own description ofsale_refund_lineas one of "the 3 append-only ledgers" was descriptive, not enforced —sale_refund_linehad no actual DB-level append-only enforcement (no REVOKE, no trigger) until DR-L below closed that gap.register_cash_entryandsale_linegenuinely were enforced at the time this entry was written (Remediation Phase 1, 2026-07-08). Full cross-module record: PROJECT_DECISIONS #38.DR-J — Remediation Phase 3 (2026-07-08): 3 separate items touched
pos, +5 columns total, no tables added/dropped. Full cross-module record: PROJECT_DECISIONS #39.Item 9 — refund tax capture (shared with
tax; see tax's own PROJECT_DECISIONS #39 entry for thetax.tax_calculationreversal-sign-convention side).sale_refund_linegainedtax_amount_cents(bigint, NOT NULL DEFAULT 0, CHECKchk_sale_refund_line_tax_amount_nonnegative >= 0) andtax_rate(numeric, NOT NULL DEFAULT 0), mirroringsale_line's own pattern.sale_refundgainedtax_refunded_amount_cents(bigint, NOT NULL DEFAULT 0, CHECKchk_sale_refund_tax_refunded_nonnegative >= 0). All 3 are plain positive POS-layer magnitudes — the actual reversal sign convention lives entirely intax.tax_calculation. Migration:packages/db/migrations/20260708220000_phase3_item9_refund_tax_reversal.sql.Item 10 —
sale_number+ the no-receipt-refund path.salegainedsale_number(text, NOT NULL), restoring an unlogged v1 erosion (v1'sdocs/old/schema/schema_modules/schema_pos.mdspecified it: "Sequential per tenant per site; format configurable"; v2's initial build dropped it with no logged reason — see §13). Deliberately not a global gapless sequence, which would require a live DB round-trip to allocate the next number, defeating this module's own offline-first design (§1). Instead it is register-session-prefixed and client-generated: convention"<register.label>-<session-local-sequence>", e.g."Register 1-0042".pos.register.labelis confirmed liveNOT NULL+UNIQUE(tenant_id, label) WHERE deleted_at IS NULL, so the prefix is always available and collision-free. The DB enforces onlyUNIQUE(tenant_id, sale_number) WHERE deleted_at IS NULL(new indexsale_tenant_id_sale_number_unique), mirroring v1's own uniqueness shape exactly — the format itself is an application-layer convention, not DB-enforced beyond uniqueness.salehad 156 live rows at build time, requiring a 3-step migration (ADD COLUMNnullable →UPDATEbackfill to'LEGACY-' || id::text→ALTER COLUMN SET NOT NULL); a single-stepADD COLUMN ... NOT NULLwould have failed against those rows.Also Item 10:
sale_refund_line.sale_line_idrelaxed fromNOT NULLto nullable (0 live rows at build time, zero backfill risk), andsale_refund_linegaineditem_variant_id(uuid, nullable, FK →inventory.item_variant.id,ON DELETE RESTRICT) as the alternative identifier for a no-receipt refund line — a customer returning merchandise with no receipt and no identifiable historicalsale_line. New CHECKchk_sale_refund_line_identification:sale_line_id IS NOT NULL OR item_variant_id IS NOT NULL(at least one identifier always required).This does NOT enable a fully anonymous walk-in return.
sale_refund.sale_iditself is unchanged — stillNOT NULL. A real sale/transaction context is still required; only the specific line within it can now go unidentified. Whether to also relaxsale_refund.sale_idto nullable (a true anonymous-walk-in-return capability) is a separate, undecided go/no-go, explicitly flagged for the architect's own decision, NOT resolved or bundled into this build — logged to OPEN_ITEMS as an open human-decision item (§11). Migration:packages/db/migrations/20260708230000_phase3_item10_sale_number_and_no_receipt_refund.sql.Item 11 — reward tender fail-closed (the
stock_movement/produced-stock half of this item belongs toinventory's own doc, not repeated here — only the pos-side tender gate). The existing fail-closed tender CHECK from DR-G was renamed and widened:chk_sale_payment_no_unvalidated_stored_value_tender→chk_sale_payment_no_unbacked_tender_type, now blockingpayment_method IN ('gift_card','store_credit','reward')instead of just the first two.rewardis a validpayment_methodenum value with no backing rewards subsystem at all — worse thangift_card/store_credit, which at least carry an unenforced forward-ref column — and was previously silently accepted with zero validation, the same bug class DR-G closed. Confirmed live: 0 rows withpayment_method='reward'existed at build time — a pure narrowing of an already fail-closed CHECK, zero rows affected. Migration:packages/db/migrations/20260708240000_phase3_item11_reward_tender_and_produced_stock.sql.DR-K — Remediation Phase 4 (2026-07-08): closes the Phase 3 open decision (anonymous walk-in return) + 3 futureproofing items touch
pos, +1 table / +17 columns total, no tables or columns dropped. Full cross-module record: PROJECT_DECISIONS #40.Closing the Phase 3 open decision — Anonymous Walk-In Return: DECIDED = ALLOW. DR-J Item 10 (Phase 3) relaxed
sale_refund_line.sale_line_idto nullable (the no-receipt-refund LINE path) but deliberately leftsale_refund.sale_iditselfNOT NULL, flagging the fuller question — a refund with no sale context at all — as a separate, undecided go/no-go explicitly reserved for the architect's own decision (§11, "Anonymous walk-in returns"). That decision is now made: ALLOW.sale_refund.sale_idis relaxed to nullable (confirmed 0 live rows at build time, zero backfill risk); new CHECKchk_sale_refund_identification:(sale_id IS NOT NULL OR reason IS NOT NULL)— a refund must identify itself SOME way, a linked sale or a documented no-receipt reason (the pre-existing nullablereasoncolumn, reused rather than adding a new one), never neither. Audit controls a real anonymous-return flow needs — a reason REQUIRED (not just present) at the UI layer, manager/actor attribution, and an approval gate for high-value anonymous refunds — are explicitly a SERVICE-LAYER requirement, NOT schema-enforced, per the same schema/service split this codebase has used throughout (e.g. Phase 3 Item 8's credit-limit enforcement, Item 12's agent kill-switch precedence chain) — logged to §11, not built here. The resulting site/register-context gap (an anonymous refund carries no way to know which site it happened at, since the only current path to a site issale_id → sale.site_id) is also disclosed, not schema-expanded beyond what was decided. Live-reproduced: a refund with bothsale_idandreasonNULL is rejected; a refund withsale_idNULL andreasonset (e.g."anonymous walk-in return, no receipt") is now ACCEPTED — previously impossible; a refund with a validsale_idand NULLreasonis unaffected (pre-existing behavior). Migration:packages/db/migrations/20260708280000_phase4_close_anonymous_return.sql.Item 14 — Fiscal periods.
platform.accounting_period(new table, owned byplatform, not repeated here) lets a tenant mark a date range'closed', with real overlap prevention viaEXCLUDE USING gist(same-tenant date ranges can never overlap; different tenants' identical ranges are unaffected). The consuming trigger,platform.flag_closed_period_business_date(), is FLAG-NOT-REJECT by design: it setsreview_status='pending'(never raises) when a row'sbusiness_datefalls inside a closed period — deliberately non-blocking, since this module's own offline-first design (§1) means a genuine June-30 sale can still sync in July, after June's period has already closed; rejecting it would silently lose the sale, while flagging it preserves both signals.pos.salegainedbusiness_date(date, NOT NULL DEFAULTCURRENT_DATE) via a 3-step migration (add nullable → backfillcreated_at::datefor then-189 rows →SET NOT NULL DEFAULT CURRENT_DATE);pos.sale_refundgot it directly (0 rows, zero backfill risk).pos.register_cash_entryALSO gainedbusiness_datePLUS its first-ever full review seam (review_status/review_reason/reviewed_by_actor_id/reviewed_at/decision_provenance) — a necessary mid-build discovery, not a planned addition: the flag trigger's ownreview_status='pending'mechanism was literally impossible without this seam existing first, since this table had no review-seam columns at all before this migration. Pre-build correction, not an inconsistency: the trigger firesBEFORE INSERT OR UPDATE OF business_dateonsale/sale_refund(a post-insert correction is plausible there), butBEFORE INSERT ONLYonregister_cash_entry— Phase 1's own pre-existingtrg_register_cash_entry_append_onlytrigger already rejects every UPDATE unconditionally, so anOR UPDATEclause would be dead code. Live-confirmed viapg_get_triggerdefthat the register_cash_entry trigger is genuinely INSERT-only, and independently re-verified that an UPDATE attempt on a freshly-inserted row is still rejected outright by the pre-existing append-only trigger. Live-tested: apos.sale/pos.sale_refundinserted withbusiness_dateinside a closed period is ACCEPTED but flaggedreview_status='pending'; one outside any closed period is unaffected (review_status='not_required'). Migration:packages/db/migrations/20260709000000_phase4_item14_fiscal_periods.sql.Item 15 — Legal entity.
platform.legal_entity(new table, owned byplatform, not repeated here) is 1:N fromplatform.tenant, letting a tenant incorporate a 2nd LLC without splitting into two tenants.pos.salegainedentity_id(uuid, nullable, FK →platform.legal_entity.id, part of that item's independently-derived 10-table rollout across the codebase — the other 9:platform.contract,platform.billing_account,admin.compliance_document,tax.tax_calculation,billing.ar_account,billing.vendor_payable,purchasing.vendor_invoice,purchasing.purchase_order,orders.order_header). Disclosed scoping rule:entity_idwas added ONLY to header tables, never to line-item children —sale_line,sale_payment, andsale_refundwere deliberately NOT given their ownentity_id; a line/payment/refund's entity is always inherited via its parentsale's FK, never needs its own column. This mirrors the same rule applied identically across all 10 tables in this item, not apos-specific decision. Migration:packages/db/migrations/20260709010000_phase4_item15_legal_entity.sql.Item 17a — Enum→catalog, additive interim (POS tender types). New table
pos.tender_type_catalog(id, code, name, category, is_active, created_at, updated_at — 7 cols, 7 seeded rows matching the existingpayment_methodCHECK-enum exactly:card,cash,charge_account,check,gift_card,reward,store_credit) + new columnpos.sale_payment.tender_type_id(uuid, nullable, FK →pos.tender_type_catalog.id). This is the identical additive-interim pattern applied across all 4 of Item 17's sub-items codebase-wide (payment terms inshared, tax jurisdiction levels intax, integration providers inadmin) — a new catalog + a new, independently-nullable FK column on the consuming table, while the OLD CHECK-enum column (sale_payment.payment_method, and its companion fail-closed CHECKschk_sale_payment_no_unbacked_tender_type/chk_sale_payment_stored_value_ref_matches_methodfrom DR-G/DR-J) stays completely unchanged. This interim gap is disclosed durably in the Drizzle TypeScript source itself (pos/catalog.ts), not just a migration-file SQL comment that could rot if migrations are ever squashed — nothing consumestender_type_idyet, so this is zero-risk scaffolding for a future full cutover, not a behavior change.tender_type_catalogis deliberately global reference data (notenant_id, no RLS), matchingidentity.permission/admin.setting_definition/tax.jurisdiction_level_catalog's established sibling design — the sole such table in this otherwise uniformly tenant-scoped module. Cross-cutting NOTE, not introduced by this phase: like every other global-reference catalog table in this codebase,tender_type_cataloggrants fullINSERT/SELECT/UPDATE/DELETEto theauthenticatedrole with no RLS restricting who can mutate it — a systemic grant-model gap, logged to §11, not fixed here. Migration:packages/db/migrations/20260709030000_phase4_item17_enum_to_catalog.sql.Independently verified by 2 separate adversarial lenses (constraint correctness/migration safety; scope discipline/disclosure integrity) — both CLEAN, zero BLOCKERs. Dedicated regression tests added to
pos-schema.spec.ts(+10 tests, sections H–K, 34/34 total): the anonymous-return CHECK (all 3 cases),tender_type_catalog(seed + FK + nullability), the flag-not-reject trigger onsale/sale_refund/register_cash_entry(including the register_cash_entry append-only-blocks-UPDATE proof), andsale.entity_id.DR-L — Header/Line Remediation, Fix #8 (2026-07-10): closes a real, live gap in
sale_refund_line's own documented immutability, plus 2 bundled schema additions. Zero tables/columns added or removed. Full cross-module record: PROJECT_DECISIONS #46. The first of 3 reopens (POS → Purchasing → Platform) executing the coordinated, 6-module "Header/Line Remediation" design (vrida-header-line-remediation-design-2026-07-10.md); Purchasing and Platform are separate, later reopens under the same effort, not detailed here.The bug, now closed.
sale_refund_linewas documented write-once/immutable since its original build (see §2's table and DR-I above), but had zero DB enforcement — Remediation Phase 1's append-only sweep (20260708140000_phase1_append_only_ledgers.sql) coveredsale_lineandregister_cash_entrybut never actually includedsale_refund_line, an omission this fix closes. Live-reproduced pre-fix: a plainUPDATE/DELETEagainst an existing row both succeeded with zero error. Fixed viaREVOKE UPDATE, DELETE ON pos.sale_refund_line FROM authenticated+ a new trigger,trg_sale_refund_line_append_only, reusingplatform.reject_append_only_mutation()verbatim — matchingsale_line's own precedent exactly, zero new PL/pgSQL. Live-reproduced post-fix: both are now rejected.2 bundled additions, taken opportunistically because POS was already reopened, neither part of fix #8 itself: (1) a prerequisite
UNIQUE(id, tenant_id)onpos.sale_line(sale_line_id_tenant_id_unique) for a future, still-DEFERRED fix (orders.order_line.sale_line_id → pos.sale_line, fix #6, not built this pass) — confirmed missing by independent verification, not assumed; (2)sale_refund_line.sale_line_id— itself a bare, non-composite FK topos.sale_linecarrying the exact cross-tenant-exposure bug class this whole remediation effort exists to close — upgraded to composite:FOREIGN KEY (sale_line_id, tenant_id) REFERENCES pos.sale_line (id, tenant_id). NULLsale_line_id(the no-receipt-refund path, DR-J Item 10) trivially satisfies a composite FK under Postgres's defaultMATCH SIMPLE— only a set, cross-tenant value is rejected.Migration:
packages/db/migrations/20260710000000_headerline_pos_fix8.sql. Schema files:pos/sale.ts(saleLinegains theUNIQUE),pos/payment.ts(saleRefundLine's FK converted to a table-level compositeforeignKey()). Verified live-reproduced and test-confirmed, fullapps/apisuite green (816/816); new coverage inpos-schema.spec.tssections L/M/N, plus a compatibility fix intax-schema.spec.ts's "K. Refund tax reversal" cleanup (now tolerates the append-only rejection instead of assuming an unconditionalDELETEwould succeed).DR-M — Phase 3 Gift Card + Store Credit build (2026-07-18): DR-G's fail-closed gate RESOLVED for gift_card/store_credit, narrowed to
reward-only. Full cross-module record: PROJECT_DECISIONS #71; full table detail:docs/database/schema_docs/billing.md.billing.gift_card/billing.store_credit_accountwere built (relocated from v1's ownposplacement — a deliberate reversal, since a stored-value instrument is a tenant LIABILITY,billing's charter).sale_payment.gift_card_id/.store_credit_idbecame REAL composite FKs — closing DR-G's own trigger contract exactly (real FKs, not just a CHECK drop).chk_sale_payment_no_unbacked_tender_typenarrowed topayment_method != 'reward';chk_sale_payment_stored_value_ref_matches_methodstrengthened to full coherence (a stored-value tender now REQUIRES its matching ref); newchk_sale_payment_stored_value_online_only(v1's own offline-first boundary — gift card/store credit balance lookup was always online-required).pos.sale_paymentgainedUNIQUE(id, tenant_id)as the prerequisite forbilling's composite FKs into it.rewardinvestigated and confirmed still unbacked —rewards(locked since #56/#60) has no pos-side linkage column, no points→money bridge, and structurally forces every redemption through areward_option(a price-adjustment shape, not a tender). All 8 named guards (negative balance, concurrency race, double-redemption, append-only both layers, cross-tenant, code-hash security, doubly-capped clawback, the CHECK relaxation) live-reproduced.PosService's future tender path must write thesale_paymentrow and itsredeemledger entry in the same transaction (not DB-enforced) — see §11.DR-N — gap-validation fix (2026-07-19):
trg_pos_sale_requires_open_sessionflag-not-reject for a late-syncing offline sale. Full record: PROJECT_DECISIONS entry (this pass); full trigger detail:docs/database/schema_docs/pos.mdTriggers section. A 2026-07-19 gap-validation read-only pass (vrida-gap-validation-2026-07-19.md, Part C Flow 1) found and live-reproduced a real, previously-undiscovered hard blocker in DR-F's own guard: an offline sale rung while its register session was genuinely open, but synced only after that session closed, was rejected outright by the originalINSERT-time check — and sinceregister_session_idis immutable after insert (DR-F), the sale was permanently stranded with no schema-level recovery path. Fixed viapackages/db/migrations/20260719000001_pos_offline_session_gate_flag_not_reject.sql:origin='offline'against a non-open session now flags (review_status='pending', a fixedreview_reason) instead of rejecting, reusingplatform.flag_closed_period_business_date()'s own established flag-not-reject idiom verbatim (including theCOALESCE-guardedreview_reasonso an existing caller-supplied reason is never clobbered).origin='online'against a non-open session is unchanged — still a hard reject (a caller bug, not a timing artifact). DR-F's own immutability guard (theUPDATEbranch) is untouched. Live-reproduced pre-fix (the exact bug, reproduced then rolled back) and post-fix (6 scenarios: offline+closed accepted+flagged; online+closed still rejected; offline+open unaffected; a replayed duplicate(tenant_id, client_uuid)still resolves as a silentON CONFLICT DO NOTHINGno-op;register_session_idimmutability holds on the flagged row; online+open unaffected) — regression tests E4–E9.DR-O — Gap-Fill Batch (2026-07-20): parked carts (A2) + gift receipt (A4), 2 of the 6 gaps closed in that batch. Full cross-module record: PROJECT_DECISIONS #74; the other 4 gaps (A1 category/brand-scoped pricing, B3 direct receiving, B6 buy-X-get-Y offers, B10 forfeited deposits) land in their own modules' docs, not repeated here. Net effect on
pos: +2 tables (parked_cart/parked_cart_line) / +23 columns, +1 column onsale_line(is_gift) — 10→12 tables, 160→183 cols. Migration:packages/db/migrations/20260720000004_pos_parked_carts_and_gift_receipt.sql.Parked carts — hold a cart, resume it later, or discard it.
pos.parked_cartlets a cashier pause an in-progress register transaction (a customer steps away, a register needs to serve someone else) and pick it back up later, rather than either losing the cart or blocking the register.statusmovesparked→resumed(linkingresumed_sale_id, the realsalethe cart became) orparked→discarded(abandoned, never became a sale) — both are terminal;chk_parked_cart_resumed_requires_saleenforces thatresumed_sale_idis set if and only ifstatus='resumed'.parked_cart_lineholds the line items (item_variant_id,qty, an advisoryunit_price_snapshot) — advisory because a parked cart can sit for hours or days, and the price it re-enters the register at is resolved fresh byPosService/resolvePrice()at resume time, never trusted from the stale snapshot.parked_cart.customer_idis nullable (an anonymous walk-in cart can be parked too, mirroringsale.customer_id's own precedent) andexpires_atis a nullable advisory field for a future cleanup sweep — no automatic expiry/discard mechanism exists yet; see §11.Why parked carts NEVER reserve stock — a deliberate v1-carried-forward decision, not an oversight. No
inventory.stock_reservationrow is ever created for a parked cart, and no code path in this migration touchesinventoryat all. This directly extends DR-D's own reasoning (no POS-side reservation table, because a POS sale completes atomically viaInventoryService.completeSale()rather than reserve-then-fulfill): a parked cart is even further from a reservation than a completing sale is — it is purely a UI/workflow convenience for the register, with no promise to the customer or the business that the stock will still be there at resume time. Reserving stock for a parked cart would require (a) a hold-expiry/release mechanism this module doesn't have, and (b) treating a paused cart as equivalent to an Orders-style pending fulfillment, which conflates two structurally different concepts this codebase has kept apart since Orders was designed (inventory.stock_reservation.source_type's CHECK still does not include'pos', confirmed live at this same build). If the stock is gone when the cart is resumed,resolvePrice()/InventoryServicesimply fail the resume the same way ringing a fresh sale on an out-of-stock item would — no new failure mode was introduced.Why the terminal-state guard trigger (
trg_parked_cart_guard_status/pos.guard_parked_cart_status()) mirrorsnotifications.delivery_attempt's own monotonic-guard shape, rather than inventing a new one. No existing trigger on anypostable matched the shape this needed — a same-row status column with exactly two dead-end terminal values that must never transition further, while tolerating an idempotent same-value re-write (a retried resume/discard call from a flaky register shouldn't hard-fail).register_session.status(open/closed) andpos_sync_conflict.status(open/resolved/ignored) are both status columns, but neither has this exact "2 of N values are terminal, re-writing the same value is a no-op, anything else out of a terminal value is rejected" guard shape, so there was no direct precedent inside this module to reuse verbatim (unlike, e.g.,trg_sale_refund_line_append_only's verbatim reuse ofplatform.reject_append_only_mutation(), DR-L).notifications.delivery_attemptwas the closest existing match codebase-wide, so its shape —IF NEW.status IS NOT DISTINCT FROM OLD.status THEN RETURN NEW; END IF;followed by a terminal-value reject — was reused as the idiom, not copied as shared code (this module gets its own function,pos.guard_parked_cart_status(), since Postgres triggers are schema-owned and the two tables' terminal-value sets differ). The same day, in the same batch,orders.order_paymentgained an identically-shaped guard (trg_order_payment_guard_status, for the new'forfeited'terminal status) — confirming this is now a reusable idiom for "a status column with terminal values," not a one-off. Live-reproduced (part of the batch's 24 named scenarios): a same-value re-write ofstatus='parked'is a silent no-op; transitioningresumed→ anything, ordiscarded→ anything, is rejected;parked→resumed(withresumed_sale_idset) andparked→discardedboth succeed.Gift receipt — a line-level, persisted-intent-only flag.
sale_line.is_gift boolean NOT NULL DEFAULT falselets a single basket mix gift and non-gift items (line-level, not a whole-sale flag, since a customer buying for themselves and a gift in the same transaction is a common real case). This column is deliberately only a persisted intent flag — it does not do anything by itself. Two capabilities it anticipates are explicitly OUT of scope for this build: (1) rendering — hiding the price on a printed/emailed receipt for gift-flagged lines is the future receipt-rendering/notificationsbuild's own work (the same trigger condition already named forreceiptin §11/§13); (2) the gift-return bearer-credit mechanism — crediting whoever presents a gift receipt (not necessarily the original purchaser) rather than the original sale's tender, isreturns' own future work, since it touches refund-destination logicreturns.return_resolutionalready owns. No CHECK or trigger governsis_gift— it is a plain boolean, exactly as deliberate as the rest of this line's fields being either "sold as normal" or "sold as normal, plus this one bit of downstream intent."
11. Deferred / Future Items
All items tracked in docs/open-items/OPEN_ITEMS.md, attributed to pos (PROJECT_DECISIONS #27). Summary for context — 10 deferred v1 tables, each with its own concrete trigger, plus 4 open questions, plus 9 items added 2026-07-07 (the fail-closed gate + 8 column/enum-level erosions found by the 19-to-9 functionality audit), plus 4 items added 2026-07-08 by Remediation Phase 3 (DR-J; 3 closed, 1 open UNDECIDED human go/no-go — see PROJECT_DECISIONS #39), plus 4 items added/closed 2026-07-08 by Remediation Phase 4 (DR-K; the anonymous-return row is now RESOLVED, 3 new rows added for its own service-layer/grant-model deferrals — see PROJECT_DECISIONS #40), plus 1 item closed 2026-07-10 by Header/Line Remediation fix #8 (DR-L; the sale_refund_line append-only-enforcement gap — see PROJECT_DECISIONS #46; the fix's own 2 bundled schema additions, a sale_line UNIQUE prerequisite and a composite-FK upgrade, need no standing OPEN_ITEMS row since neither is itself deferred), plus 2 items added 2026-07-20 by the Gap-Fill Batch (DR-O; gift-receipt rendering + the gift-return bearer-credit mechanism deferred to future builds, and a parked-cart expiry sweep flagged as a not-yet-built service-layer requirement — see PROJECT_DECISIONS #74):
| Item | Status | Trigger |
|---|---|---|
sale_line_tax (full multi-jurisdiction tax breakdown) |
deferred — UPGRADED 2026-07-07, PRE-CUSTOMER decision | Per-jurisdiction tax stacking is unrecoverable in the built schema (flat tax_amount_cents/tax_rate only) — must be resolved before the first customer transacts in a multi-jurisdiction/stacked-tax location, OR when a real Tax module is built. Not open-ended. |
receipt (delivery tracking) |
deferred | When a notifications module is built. |
gift_card |
deferred | When gift_card/store_credit stored-value subsystems are built. Tender-tagging preserved via a payment_method enum value + sale_payment.gift_card_id forward-ref column. |
gift_card_transaction |
deferred | Same trigger as gift_card. |
store_credit |
deferred | Same trigger as gift_card. sale_payment.store_credit_id forward-ref column preserved. |
store_credit_transaction |
deferred | Same trigger as gift_card. |
layaway_payment |
deferred | When installment-payment-plan support is prioritized. |
sale_template |
deferred | When saved-cart/recurring-order UX is prioritized. |
sale_template_line |
deferred | Same trigger as sale_template. |
guarantee |
deferred | When guarantee issuance/claim-instance tracking is prioritized. Caveat: CROSS_MODULE_CONTRACTS.md's Files section names guarantee.signature_ref → files.file.id as a live (non-stale) seam that cannot be honored until guarantee itself is built — this caveat travels with the table's own deferral. |
sale_line_tax full breakdown vs. flat tax_amount/tax_rate on sale_line |
open question | Confirmed deferred — see the sale_line_tax row above. |
| The 10 deferred tables above, collectively | open question | Confirmed deferred with concrete per-table triggers, listed above. |
| Rewards/Offers seams | open question | Both flagged stale in CROSS_MODULE_CONTRACTS.md; neither module exists in v2. Stay unhonored this pass — not silently ignored. |
sale_payment.status enum (Stripe-vocabulary-derived) |
open question | May need revisiting once Payments' actual Stripe Terminal integration is built against a real webhook payload. |
sale.search_vector (dropped during build) |
deferred | See DR-E. Trigger: if/when a free-text field (e.g. job_reference/po_number) is added to sale. |
DR-1 (InventoryService.completeSale() stock-oversell detection) |
open, not yet satisfied | InventoryService doesn't exist yet. Trigger: when InventoryService is built — must create a pos_sync_conflict row (conflict_type='stock_oversell') rather than allow negative stock or hard-fail. |
| DR-2 (refund-issuance sum-never-exceeds-payments enforcement) | open, not yet satisfied | The refund-issuance path doesn't exist yet. Trigger: when the refund-issuance service path is built. |
DR-3 (idempotency_key derivation from client_uuid) |
open, not yet satisfied | POSService/sync pipeline don't exist yet. Trigger: when the offline-sync write path is built. |
POSService (service layer) |
open | No POSService exists yet — schema-only this pass, same pattern as every other module's deferred service layer. Trigger: when POSService is built. |
chk_sale_payment_no_unbacked_tender_type (DR-G, renamed + widened by DR-J Item 11) |
open, TEMPORARY restriction | When the gift_card/store_credit/rewards subsystems are built — drop or loosen this CHECK to allow validated tenders, replacing it with real FKs, not just removing the restriction. |
sale column erosions: cart hold/resume, B2B fields, loyalty-points snapshot (DR-H) |
cart hold/resume RESOLVED 2026-07-20 (Gap-Fill Batch, DR-O); B2B fields + loyalty snapshot still deferred, 2 rows | Cart hold/resume: closed, but via a different shape than the original v1 erosion described — not sale.held_at/hold_expires_at columns restored on sale itself, but a wholly new pre-sale pos.parked_cart/parked_cart_line table pair (a held cart becomes a real sale only on resume, rather than an existing sale row being held). See DR-O and PROJECT_DECISIONS #74. B2B fields: when B2B/contractor-order support is prioritized. Loyalty snapshot: when a Rewards/loyalty module is designed/built in v2. |
sale_line column erosions: comp/sample/replacement distinction, price-override tracking (DR-H) |
deferred, 2 rows | When comp/sample/replacement line accounting is prioritized; when manual price-override audit tracking is prioritized. |
register column erosion: hardware-pairing config (DR-H) |
deferred | When terminal hardware integration (card reader/printer/cash-drawer pairing) is built. |
register_cash_entry.entry_type narrowing 6→3 (DR-H) |
deferred | When structured drop-vs-payout distinction or no-sale-open audit tracking is prioritized. no_sale has no v2 equivalent at all; open/close are likely redundant with register_session's own columns, not lost. |
register_cash_entry.id/sale_line.id/sale_refund_line.id PK-generation change to platform.uuid_generate_v7() (DR-I) |
done, 2026-07-08 | Remediation Phase 2, Item 6 — closed; no further action. See PROJECT_DECISIONS #38. |
Anonymous walk-in returns — relaxing sale_refund.sale_id to nullable (DR-J Item 10) |
RESOLVED = ALLOW, 2026-07-08 (DR-K) | Was: explicitly flagged for the architect's own decision — not a technical trigger. Now: sale_refund.sale_id relaxed to nullable, chk_sale_refund_identification enforces "sale OR reason, never neither." See PROJECT_DECISIONS #40. |
sale_refund_line.tax_amount_cents/.tax_rate, sale_refund.tax_refunded_amount_cents (DR-J Item 9) |
done, 2026-07-08 | Remediation Phase 3, Item 9 — closed; these are plain positive magnitudes, reversal-sign convention lives in tax.tax_calculation. See PROJECT_DECISIONS #39. |
sale.sale_number restoration + sale_refund_line no-receipt identification (DR-J Item 10) |
done, 2026-07-08 | Remediation Phase 3, Item 10 — closed; format is application-layer convention, DB enforces uniqueness only. See PROJECT_DECISIONS #39. |
chk_sale_payment_no_unbacked_tender_type widening to include reward (DR-J Item 11) |
done, 2026-07-08 | Remediation Phase 3, Item 11 — closed as a rename+widen; the underlying TEMPORARY-restriction row above stays open until the subsystems are built. See PROJECT_DECISIONS #39. |
| Anonymous-return service-layer audit controls (DR-K) | open, not yet satisfied | A reason REQUIRED (not just present) at the UI layer, manager/actor attribution, and an approval gate for high-value anonymous refunds are all documented service-layer requirements. Trigger: when the refund-issuance service path is built (same trigger as DR-2 above). |
| Anonymous-return site/register-context gap (DR-K) | open question | An anonymous refund (sale_id IS NULL) carries no way to know which site it happened at — the only current path to a site is sale_id → sale.site_id. Trigger: if per-refund site attribution becomes a real requirement for anonymous returns. |
tender_type_id/tender_type_catalog vs. legacy payment_method enum sync gap (DR-K Item 17a) |
open, TEMPORARY additive-interim scaffold | The new FK column and catalog are deliberately NOT yet kept in sync with the legacy CHECK-enum or its fail-closed companion CHECKs. Trigger: when the tender-type enum→catalog cutover is actually executed — the replacement must resolve both mechanisms into one. |
tender_type_catalog grant-model gap (DR-K, cross-cutting, not introduced by this phase) |
open question | Like every global-reference catalog table in this codebase, it grants full INSERT/SELECT/UPDATE/DELETE to authenticated with no RLS restricting mutation. Trigger: when a distinct platform-admin-only/tenant-read-only role/grant model is designed for catalog tables generally (not a pos-specific fix). |
platform.legal_entity/sale.entity_id (DR-K Item 15) |
done, 2026-07-08 | Remediation Phase 4, Item 15 — closed; entity_id added to sale only (header-only scoping rule), nullable, no service-layer consumer yet. See PROJECT_DECISIONS #40. |
sale_refund_line documented-but-unenforced write-once/immutability (DR-L) |
done, 2026-07-10 | Header/Line Remediation fix #8 — closed; REVOKE UPDATE, DELETE + trg_sale_refund_line_append_only reusing platform.reject_append_only_mutation(), matching sale_line's own Phase 1 precedent. See PROJECT_DECISIONS #46. |
pos.sale_line missing UNIQUE(id, tenant_id) / sale_refund_line.sale_line_id bare (non-composite) FK (DR-L) |
done, 2026-07-10 | Header/Line Remediation fix #8's 2 bundled additions — closed; sale_line gained the UNIQUE (prerequisite for the still-DEFERRED fix #6, orders.order_line.sale_line_id), sale_refund_line.sale_line_id upgraded to a composite FK. See PROJECT_DECISIONS #46. |
Fiscal-period flag trigger + business_date (DR-K Item 14) |
done, 2026-07-08 | Remediation Phase 4, Item 14 — closed; flag-not-reject mechanism live on sale/sale_refund/register_cash_entry. No consuming review/resolution UI or service logic exists yet for the resulting review_status='pending' rows. Trigger: when POSService/an admin review UI is built. |
parked_cart/parked_cart_line built, no stock reservation (DR-O, Gap-Fill Batch A2) |
done, 2026-07-20 | Gap-Fill Batch — closed; pos.parked_cart/parked_cart_line built, deliberately never reserving stock (see DR-O). See PROJECT_DECISIONS #74. |
parked_cart.expires_at sweep/auto-discard |
open, not yet satisfied | expires_at is a nullable advisory column with no automatic discard mechanism yet — a documented service-layer requirement for whoever builds POSService's parked-cart resume/discard path. Trigger: when POSService is built. |
sale_line.is_gift built, rendering + gift-return bearer credit deferred (DR-O, Gap-Fill Batch A4) |
done (flag), rendering/bearer-credit open | Gap-Fill Batch — the flag itself is closed (sale_line.is_gift, persisted-intent-only). Receipt rendering (hiding price for gift-flagged lines) is the future receipt-rendering/notifications build's own work (same trigger as the receipt row above); the gift-return bearer-credit mechanism is returns' own future work. See PROJECT_DECISIONS #74. |
12. Cross-Module Seams
Seams are cataloged in docs/modules/CROSS_MODULE_CONTRACTS.md (referenced, not restated here in full). Key relationships:
- pos → multi_loc:
register.site_id,sale.site_id→multi_loc.site.id(enforced FKs, NOT NULL, ON DELETE RESTRICT). New (Gap-Fill Batch, DR-O):parked_cart.site_id→multi_loc.site (id, tenant_id)(enforced composite FK, NOT NULL). - pos → inventory:
sale_line.item_variant_id→inventory.item_variant.id(enforced FK, ON DELETE RESTRICT).InventoryService.completeSale()is the service-layer seam for the stock decrement (not yet built —InventoryServicedoesn't exist);inventory.stock.available_qtyis read-only nettingposrelies on for sellability, not apos-owned mechanism. New (Remediation Phase 3, DR-J Item 10):sale_refund_line.item_variant_id→inventory.item_variant.id(enforced FK, nullable, ON DELETE RESTRICT) — the no-receipt-refund alternative identifier when there's no historicalsale_lineto point to. New (Gap-Fill Batch, DR-O):parked_cart_line.item_variant_id→inventory.item_variant (id, tenant_id)(enforced composite FK, NOT NULL) — but deliberately NOTinventory.stock_reservation: a parked cart never reserves stock (see DR-O). - pos → pricing:
sale_line.resolving_price_rule_id→pricing.price_rule.id(enforced FK, nullable, ON DELETE RESTRICT). Pricing's Hard Contract 1 is now SATISFIED by pos (§7) — still pending onorders' own side. - pos → crm:
sale.customer_id→crm.customer.id(enforced FK, nullable, ON DELETE RESTRICT). Anonymous sales are fully supported. New (Gap-Fill Batch, DR-O):parked_cart.customer_id→crm.customer.id(nullable, bare FK — mirrorssale.customer_id's own precedent; an anonymous cart can be parked too). - pos → shared:
sale/sale_line/sale_payment.currency_code→shared.currency.iso_code(enforced FKs, ON DELETE RESTRICT). - pos → identity: every
*_by_actor_idcolumn →identity.actor.id(enforced FKs). No new authority mechanism — pure consumer ofidentity.agent_duty_grant(§9). - orders → pos (NEW seam, orders not built yet):
orders.order_header.fulfilled_sale_id → pos.sale— FK lives entirely on Orders' side;pos.saleneeds no reciprocal column. Direction: Orders → pos. "Link-don't-convert; tax finalizes at POS." - pos → Payments (forward-ref, Payments not built yet):
sale_payment.stripe_payment_intent_id(forward-ref, no FK) andsale_payment.charge_account_ref(forward-ref into futurebilling.ar_charge) — mirrors Billing's ownar_payment/ap_paymentprecedent. Direction: pos → Payments, deferred FK, wired at Payments' own lock. - pos → platform (NEW seams, Remediation Phase 4, DR-K):
sale.entity_id → platform.legal_entity.id(enforced FK, nullable, Item 15 — header-only scoping, nosale_line/sale_payment/sale_refundequivalent).platform.flag_closed_period_business_date()— a shared trigger function owned byplatform, consumed bysale/sale_refund/register_cash_entryvia 3 triggers (Item 14) to flag (never reject) abusiness_datefalling inside a closedplatform.accounting_period.
13. v1 Exclusions Re-Confirmed
The 10 deferred v1 tables listed in §11 remain out of scope for this pass, each with its own concrete trigger — not silently dropped. The guarantee table's Files-seam caveat (guarantee.signature_ref → files.file.id) travels with its own deferral row and must not be dropped when guarantee is eventually picked back up. Rewards and Offers integration (points earned/redeemed on a sale, coupon redemption tied to a sale) remain fully out of scope, consistent with those modules' own not-yet-built status in v2.