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 policypricing.price_rule resolves what a variant costs; pos.sale_line only snapshots the resolved/charged amounts per Pricing's Hard Contract 1 (honored verbatim, see §7). pos never derives a price itself.
  • Stock truth / decrement logicinventory.stock.available_qty (an existing generated column netting on_hand_qty - reserved_qty) is the source of truth. A completed sale calls InventoryService.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 ordersorders (not built yet) owns the order lifecycle; pos.sale is the fulfillment event an order links to, not the other way around. pos.sale needs no reciprocal column for this.
  • Card/Terminal payment processing — Payments (not built yet) owns the actual Stripe Terminal integration; sale_payment only carries forward-ref columns (stripe_payment_intent_id, charge_account_ref) for it to fill in later.
  • Tax computation/breakdownsale_line.tax_treatment states whether the snapshotted amount is tax-inclusive/-exclusive (Pricing's Hard Contract 1 field); full multi-jurisdiction tax breakdown is sale_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 from parked_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.sale carries 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:

  • platformplatform.tenant is the FK target for every tenant-scoped table; platform.set_updated_at() trigger applies to register, register_session, sale, sale_payment, sale_refund, and (Remediation Phase 4) tender_type_catalog (6 tables — not register_cash_entry, sale_line, sale_refund_line, pos_sync_conflict, which are append-only/child rows). New (Remediation Phase 4): sale.entity_idplatform.legal_entity.id (nullable); platform.flag_closed_period_business_date() — a shared trigger function owned by platform, consumed by sale/sale_refund/register_cash_entry to flag (never reject) a business_date falling inside a closed platform.accounting_period.
  • multi_locregister.site_id and sale.site_id (both FK → multi_loc.site, NOT NULL, ON DELETE RESTRICT) — every register and every sale belongs to exactly one site.
  • inventorysale_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_qty is read (not written) by pos to confirm sellability.
  • pricingsale_line.resolving_price_rule_id (nullable FK → pricing.price_rule, ON DELETE RESTRICT — NULL when no rule matched and bare base_price_cents was used) is the traceability pointer; the accompanying snapshot columns honor Pricing's Hard Contract 1 verbatim (§7).
  • crmsale.customer_id (nullable FK → crm.customer, ON DELETE RESTRICT) — anonymous sales are fully supported; a customer is linked when present.
  • sharedsale/sale_line/sale_payment .currency_code (FK → shared.currency.iso_code, ON DELETE RESTRICT) for global currency validation.
  • identity — every *_by_actor_id column across the tenant-scoped tables → identity.actor.id, never identity.identity_user directly, 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 resulting pos_sync_conflict row. This is system-recorded observation, not transaction creation — the underlying sale/sale_payment rows 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 a sale, sale_payment, or sale_refund row. 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 way pricing.price_rule has review_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:

  1. Duplicate-sale replay (same logical sale, same client_uuid, arrives twice from the same device after reconnecting) — solved entirely by the UNIQUE (tenant_id, client_uuid) WHERE deleted_at IS NULL dedup index on sale/sale_payment/sale_refund, via INSERT ... 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.
  2. 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:

  1. resolved_amount_minor_units (bigint, NOT NULL) — the pre-rounding resolved amount.
  2. charged_amount_minor_units (bigint, NOT NULL) — the actual amount charged after any PricingService display-rounding.
  3. currency_code (char(3)).
  4. tax_treatment (text).
  5. resolving_price_rule_id (nullable FK → pricing.price_rule) — NULL when no rule matched and bare base_price_cents was used.
  6. resolved_quantity (numeric) — the quantity the price was resolved against, disambiguating which min_qty tier 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_grant scope exists for creating a sale, sale_payment, or sale_refund. These are always human-initiated, system-recorded — there is no authority_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_conflict creation: an agent/scheduled job flagging a detected anomaly is a plausible may_act_alone action (observational — it records that a conflict exists; it does not resolve it, move money, or adjust stock), directly analogous to ai.agent_execution's own logging precedent. Corrected here (2026-07-07, post-build verification): pos_sync_conflict gets its own dedicated review seam (status/resolved_by_actor_id/resolution_note/resolved_at) but deliberately carries no automation_source column — 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 full review_status/decision_provenance set is NOT omitted from the transactional tables — register_session, sale, sale_payment, and sale_refund all 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. an inventory.stock_movement correction row, or a sale_line void) — 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.actor instead of identity.identity_user, (2) the addition of the offline-sync column set (client_uuid/origin/sync_status/idempotency_key/synced_at) across sale/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 on sale_line, which v1 predates entirely (pricing did 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_uuid unique 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.status deliberately 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 the sale_refund rows themselves — which could drift (e.g. a partial refund leaving the status ambiguous between "refunded" and "completed"). Keeping sale.total_minor_units immutable-gross (live-tested, test D1) and computing net-refunded position as sum(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 calls InventoryService.completeSale() directly rather than reserve-then-fulfill, so no pos-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_vector column. The PROPOSE+STOP design draft listed a generated tsvector/GIN search_vector column on sale, matching the precedent applied to item/item_variant/customer. This was DROPPED during build: unlike those 3 tables, sale has no free-text field (name/description/SKU) to feed it — the only candidate was status, 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 future job_reference/po_number-style free-text field is added to sale), not silently dropped from the design.

  • DR-F — sale.register_session_id is immutable after insert, patched same-day. trg_pos_sale_requires_open_session originally validated register_session_id at INSERT only. 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 then UPDATE 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 via packages/db/migrations/20260707010000_pos_sale_session_immutable.sql, extending the trigger to BEFORE INSERT OR UPDATE OF register_session_id, rejecting any UPDATE that changes the value. Live-tested (test E3).

  • DR-G — sale_payment fail-closed gate on gift_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_method already permits payment_method IN ('gift_card','store_credit'), and gift_card_id/store_credit_id exist 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) require gift_card_id/store_credit_id NOT NULL when 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): unvalidated gift_card/store_credit tenders 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 fabricated gift_card_id inserted 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. Added chk_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: sale lost 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_line lost its comp/sample/replacement line_type distinction and manual price-override tracking; register lost hardware-pairing config; register_cash_entry.entry_type narrowed 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 extending SCHEMA_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, and sale_refund_line.id had their DEFAULT changed from gen_random_uuid() to platform.uuid_generate_v7() (Remediation Phase 2, Item 6; sale_line is 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_conflict was considered but explicitly EXCLUDED: its status column is an explicit mutable open/resolved/ignored human-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 of sale_refund_line as one of "the 3 append-only ledgers" was descriptive, not enforced — sale_refund_line had no actual DB-level append-only enforcement (no REVOKE, no trigger) until DR-L below closed that gap. register_cash_entry and sale_line genuinely 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 the tax.tax_calculation reversal-sign-convention side). sale_refund_line gained tax_amount_cents (bigint, NOT NULL DEFAULT 0, CHECK chk_sale_refund_line_tax_amount_nonnegative >= 0) and tax_rate (numeric, NOT NULL DEFAULT 0), mirroring sale_line's own pattern. sale_refund gained tax_refunded_amount_cents (bigint, NOT NULL DEFAULT 0, CHECK chk_sale_refund_tax_refunded_nonnegative >= 0). All 3 are plain positive POS-layer magnitudes — the actual reversal sign convention lives entirely in tax.tax_calculation. Migration: packages/db/migrations/20260708220000_phase3_item9_refund_tax_reversal.sql.

    Item 10 — sale_number + the no-receipt-refund path. sale gained sale_number (text, NOT NULL), restoring an unlogged v1 erosion (v1's docs/old/schema/schema_modules/schema_pos.md specified 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.label is confirmed live NOT NULL + UNIQUE(tenant_id, label) WHERE deleted_at IS NULL, so the prefix is always available and collision-free. The DB enforces only UNIQUE(tenant_id, sale_number) WHERE deleted_at IS NULL (new index sale_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. sale had 156 live rows at build time, requiring a 3-step migration (ADD COLUMN nullable → UPDATE backfill to 'LEGACY-' || id::textALTER COLUMN SET NOT NULL); a single-step ADD COLUMN ... NOT NULL would have failed against those rows.

    Also Item 10: sale_refund_line.sale_line_id relaxed from NOT NULL to nullable (0 live rows at build time, zero backfill risk), and sale_refund_line gained item_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 historical sale_line. New CHECK chk_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_id itself is unchanged — still NOT NULL. A real sale/transaction context is still required; only the specific line within it can now go unidentified. Whether to also relax sale_refund.sale_id to 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 to inventory'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_tenderchk_sale_payment_no_unbacked_tender_type, now blocking payment_method IN ('gift_card','store_credit','reward') instead of just the first two. reward is a valid payment_method enum value with no backing rewards subsystem at all — worse than gift_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 with payment_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_id to nullable (the no-receipt-refund LINE path) but deliberately left sale_refund.sale_id itself NOT 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_id is relaxed to nullable (confirmed 0 live rows at build time, zero backfill risk); new CHECK chk_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 nullable reason column, 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 is sale_id → sale.site_id) is also disclosed, not schema-expanded beyond what was decided. Live-reproduced: a refund with both sale_id and reason NULL is rejected; a refund with sale_id NULL and reason set (e.g. "anonymous walk-in return, no receipt") is now ACCEPTED — previously impossible; a refund with a valid sale_id and NULL reason is unaffected (pre-existing behavior). Migration: packages/db/migrations/20260708280000_phase4_close_anonymous_return.sql.

    Item 14 — Fiscal periods. platform.accounting_period (new table, owned by platform, not repeated here) lets a tenant mark a date range 'closed', with real overlap prevention via EXCLUDE 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 sets review_status='pending' (never raises) when a row's business_date falls 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.sale gained business_date (date, NOT NULL DEFAULT CURRENT_DATE) via a 3-step migration (add nullable → backfill created_at::date for then-189 rows → SET NOT NULL DEFAULT CURRENT_DATE); pos.sale_refund got it directly (0 rows, zero backfill risk). pos.register_cash_entry ALSO gained business_date PLUS 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 own review_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 fires BEFORE INSERT OR UPDATE OF business_date on sale/sale_refund (a post-insert correction is plausible there), but BEFORE INSERT ONLY on register_cash_entry — Phase 1's own pre-existing trg_register_cash_entry_append_only trigger already rejects every UPDATE unconditionally, so an OR UPDATE clause would be dead code. Live-confirmed via pg_get_triggerdef that 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: a pos.sale/pos.sale_refund inserted with business_date inside a closed period is ACCEPTED but flagged review_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 by platform, not repeated here) is 1:N from platform.tenant, letting a tenant incorporate a 2nd LLC without splitting into two tenants. pos.sale gained entity_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_id was added ONLY to header tables, never to line-item children — sale_line, sale_payment, and sale_refund were deliberately NOT given their own entity_id; a line/payment/refund's entity is always inherited via its parent sale's FK, never needs its own column. This mirrors the same rule applied identically across all 10 tables in this item, not a pos-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 existing payment_method CHECK-enum exactly: card, cash, charge_account, check, gift_card, reward, store_credit) + new column pos.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 in shared, tax jurisdiction levels in tax, integration providers in admin) — 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 CHECKs chk_sale_payment_no_unbacked_tender_type/chk_sale_payment_stored_value_ref_matches_method from 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 consumes tender_type_id yet, so this is zero-risk scaffolding for a future full cutover, not a behavior change. tender_type_catalog is deliberately global reference data (no tenant_id, no RLS), matching identity.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_catalog grants full INSERT/SELECT/UPDATE/DELETE to the authenticated role 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 on sale/sale_refund/register_cash_entry (including the register_cash_entry append-only-blocks-UPDATE proof), and sale.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_line was 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) covered sale_line and register_cash_entry but never actually included sale_refund_line, an omission this fix closes. Live-reproduced pre-fix: a plain UPDATE/DELETE against an existing row both succeeded with zero error. Fixed via REVOKE UPDATE, DELETE ON pos.sale_refund_line FROM authenticated + a new trigger, trg_sale_refund_line_append_only, reusing platform.reject_append_only_mutation() verbatim — matching sale_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) on pos.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 to pos.sale_line carrying 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). NULL sale_line_id (the no-receipt-refund path, DR-J Item 10) trivially satisfies a composite FK under Postgres's default MATCH SIMPLE — only a set, cross-tenant value is rejected.

    Migration: packages/db/migrations/20260710000000_headerline_pos_fix8.sql. Schema files: pos/sale.ts (saleLine gains the UNIQUE), pos/payment.ts (saleRefundLine's FK converted to a table-level composite foreignKey()). Verified live-reproduced and test-confirmed, full apps/api suite green (816/816); new coverage in pos-schema.spec.ts sections L/M/N, plus a compatibility fix in tax-schema.spec.ts's "K. Refund tax reversal" cleanup (now tolerates the append-only rejection instead of assuming an unconditional DELETE would 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_account were built (relocated from v1's own pos placement — a deliberate reversal, since a stored-value instrument is a tenant LIABILITY, billing's charter). sale_payment.gift_card_id/.store_credit_id became REAL composite FKs — closing DR-G's own trigger contract exactly (real FKs, not just a CHECK drop). chk_sale_payment_no_unbacked_tender_type narrowed to payment_method != 'reward'; chk_sale_payment_stored_value_ref_matches_method strengthened to full coherence (a stored-value tender now REQUIRES its matching ref); new chk_sale_payment_stored_value_online_only (v1's own offline-first boundary — gift card/store credit balance lookup was always online-required). pos.sale_payment gained UNIQUE(id, tenant_id) as the prerequisite for billing's composite FKs into it. reward investigated and confirmed still unbackedrewards (locked since #56/#60) has no pos-side linkage column, no points→money bridge, and structurally forces every redemption through a reward_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 the sale_payment row and its redeem ledger entry in the same transaction (not DB-enforced) — see §11.

  • DR-N — gap-validation fix (2026-07-19): trg_pos_sale_requires_open_session flag-not-reject for a late-syncing offline sale. Full record: PROJECT_DECISIONS entry (this pass); full trigger detail: docs/database/schema_docs/pos.md Triggers 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 original INSERT-time check — and since register_session_id is immutable after insert (DR-F), the sale was permanently stranded with no schema-level recovery path. Fixed via packages/db/migrations/20260719000001_pos_offline_session_gate_flag_not_reject.sql: origin='offline' against a non-open session now flags (review_status='pending', a fixed review_reason) instead of rejecting, reusing platform.flag_closed_period_business_date()'s own established flag-not-reject idiom verbatim (including the COALESCE-guarded review_reason so 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 (the UPDATE branch) 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 silent ON CONFLICT DO NOTHING no-op; register_session_id immutability 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 on sale_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_cart lets 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. status moves parkedresumed (linking resumed_sale_id, the real sale the cart became) or parkeddiscarded (abandoned, never became a sale) — both are terminal; chk_parked_cart_resumed_requires_sale enforces that resumed_sale_id is set if and only if status='resumed'. parked_cart_line holds the line items (item_variant_id, qty, an advisory unit_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 by PosService/resolvePrice() at resume time, never trusted from the stale snapshot. parked_cart.customer_id is nullable (an anonymous walk-in cart can be parked too, mirroring sale.customer_id's own precedent) and expires_at is 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_reservation row is ever created for a parked cart, and no code path in this migration touches inventory at all. This directly extends DR-D's own reasoning (no POS-side reservation table, because a POS sale completes atomically via InventoryService.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()/InventoryService simply 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()) mirrors notifications.delivery_attempt's own monotonic-guard shape, rather than inventing a new one. No existing trigger on any pos table 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) and pos_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 of platform.reject_append_only_mutation(), DR-L). notifications.delivery_attempt was 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_payment gained 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 of status='parked' is a silent no-op; transitioning resumed → anything, or discarded → anything, is rejected; parkedresumed (with resumed_sale_id set) and parkeddiscarded both succeed.

    Gift receipt — a line-level, persisted-intent-only flag. sale_line.is_gift boolean NOT NULL DEFAULT false lets 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/notifications build's own work (the same trigger condition already named for receipt in §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, is returns' own future work, since it touches refund-destination logic returns.return_resolution already owns. No CHECK or trigger governs is_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_idmulti_loc.site.id (enforced FKs, NOT NULL, ON DELETE RESTRICT). New (Gap-Fill Batch, DR-O): parked_cart.site_idmulti_loc.site (id, tenant_id) (enforced composite FK, NOT NULL).
  • pos → inventory: sale_line.item_variant_idinventory.item_variant.id (enforced FK, ON DELETE RESTRICT). InventoryService.completeSale() is the service-layer seam for the stock decrement (not yet built — InventoryService doesn't exist); inventory.stock.available_qty is read-only netting pos relies on for sellability, not a pos-owned mechanism. New (Remediation Phase 3, DR-J Item 10): sale_refund_line.item_variant_idinventory.item_variant.id (enforced FK, nullable, ON DELETE RESTRICT) — the no-receipt-refund alternative identifier when there's no historical sale_line to point to. New (Gap-Fill Batch, DR-O): parked_cart_line.item_variant_idinventory.item_variant (id, tenant_id) (enforced composite FK, NOT NULL) — but deliberately NOT inventory.stock_reservation: a parked cart never reserves stock (see DR-O).
  • pos → pricing: sale_line.resolving_price_rule_idpricing.price_rule.id (enforced FK, nullable, ON DELETE RESTRICT). Pricing's Hard Contract 1 is now SATISFIED by pos (§7) — still pending on orders' own side.
  • pos → crm: sale.customer_idcrm.customer.id (enforced FK, nullable, ON DELETE RESTRICT). Anonymous sales are fully supported. New (Gap-Fill Batch, DR-O): parked_cart.customer_idcrm.customer.id (nullable, bare FK — mirrors sale.customer_id's own precedent; an anonymous cart can be parked too).
  • pos → shared: sale/sale_line/sale_payment .currency_codeshared.currency.iso_code (enforced FKs, ON DELETE RESTRICT).
  • pos → identity: every *_by_actor_id column → identity.actor.id (enforced FKs). No new authority mechanism — pure consumer of identity.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.sale needs 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) and sale_payment.charge_account_ref (forward-ref into future billing.ar_charge) — mirrors Billing's own ar_payment/ap_payment precedent. 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, no sale_line/sale_payment/sale_refund equivalent). platform.flag_closed_period_business_date() — a shared trigger function owned by platform, consumed by sale/sale_refund/register_cash_entry via 3 triggers (Item 14) to flag (never reject) a business_date falling inside a closed platform.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.

Last modified: Jul 14, 2026, 1:32 PM PT
On this page
Esc