pricing — Module Spec

1. Purpose

pricing owns the tenant's pricing-policy layer: what a given item variant costs a given customer, at a given site, at a given quantity, on a given date — and the append-only history of how that price got there. It is the first module of the sell path (module #13 in the v2 build order) and is deliberately upstream of, and consumed by, POS and Orders (neither built yet). pricing does not own transaction-time discounting (cashier overrides, coupons applied at the register) — that stays POS/Orders' own domain, unchanged from v1's governing boundary. pricing also does not own the base price itself (inventory.item_variant.base_price_cents remains the source of truth); pricing only layers rules on top of it, never duplicates it.

This module's design pass is unusual in its provenance: an outside-expert gap analysis (3 independent cold-read critiques plus this project's own analysis) compared the original 4-table v1-carried-forward design against standard commerce/pricing-engine patterns (Odoo, Shopify, Magento, NetSuite, Stripe) and found 6 real, schema-fixable gaps plus 3 non-schema gaps significant enough to require binding cross-module hard contracts rather than soft deferrals. All 6 schema gaps are resolved in this build. Design-phase verification then ran twice — once against the original 4-table proposal, once again against the round-2 expanded design after the 6 gaps were folded in — each round via an independent 3-agent audit (Section 4 completeness audit + adversarial verification + a third specialized check), finding and fixing real bugs both times before this module was built. The full bug history is in §10 below.

2. Ownership

Owns — 4 tables, 75 columns:

Table Cols Role
price_level 11 Named pricing tiers (retail, wholesale, member)
price_rule 33 The workhorse — every priced rule, any scope, any kind
price_list_assignment 19 Which customer/group is assigned to which price_level
price_change_log 12 Append-only audit trail of price/assignment changes

Does NOT own:

  • The base price itselfinventory.item_variant.base_price_cents is the source of truth. pricing only layers rules on top; a price_rule miss falls through to the bare base price, unmodified.
  • Transaction-time discounting — cashier overrides, register-applied coupons, and any other point-of-sale-moment discount stay POS/Orders' domain, not pricing's. This is a v1 boundary carried forward unchanged.
  • Precedence-resolution logic — which of several matching price_rule candidates wins is PricingService application logic, not a DB constraint. "Do not add DB constraints trying to enforce pricing precedence" is a v1 governing decision carried forward as-is.
  • Full promotion/campaign mechanics — usage caps, redemption limits, coupon codes, and stacking rules (the way Shopify Discounts / Magento Cart Price Rules / SFCC Promotions model a full Promotion header object) are explicitly out of scope this pass. price_rule.campaign_label is a lightweight grouping tag only, not that larger feature (see DR-11).
  • Tax computationprice_rule.tax_treatment states whether a price is tax-inclusive or -exclusive; it does not compute tax. A Tax module (not yet built) owns that.
  • The sale/order line-item record itselfpos.sale_line/the Orders equivalent (neither built yet) are what snapshot a resolved price at transaction time. pricing supplies resolvePrice(); it does not own the transaction record. See the Hard Contracts in §7.

3. Layer & Dependencies

Tenant-scoped policy layer, sitting between master data (Inventory, CRM, Multi-Location) and the not-yet-built transactional modules (POS, Orders) that consume it.

Depends on:

  • platformplatform.tenant is the FK target for every tenant-scoped table; platform.set_updated_at() trigger on the 3 mutable tables (price_level, price_rule, price_list_assignment — not price_change_log, which is append-only).
  • inventoryprice_rule.item_variant_id (FK → inventory.item_variant, NOT NULL, ON DELETE RESTRICT) is the anchor every rule prices against; price_change_log.item_variant_id (same FK, NOT NULL) for the same reason. cost_plus_percent-typed rules additionally resolve live against inventory.item_variant.avg_cost_cents at resolution time (never cached — see DR-9).
  • multi_locprice_rule.site_id (nullable FK → multi_loc.site, ON DELETE RESTRICT) for site-scoped rules.
  • crmprice_rule.customer_id/customer_group_id, price_list_assignment.customer_id/customer_group_id (nullable FKs → crm.customer/crm.customer_group) for customer- and group-scoped rules and tier assignments.
  • sharedprice_rule.currency_code (FK → shared.currency.iso_code, NOT NULL, ON DELETE RESTRICT), snapshotted at insert time (see DR-8).
  • identity — every actor-attribution column (created_by_actor_id/updated_by_actor_id/reviewed_by_actor_id on price_rule/price_list_assignment; changed_by_actor_id on price_change_log) FKs to identity.actor, never identity.identity_user directly (a v1 deviation this build corrects — v1 predates the agent-actor pattern). No new authority mechanism is introduced — pricing is a pure consumer of identity.agent_duty_grant (see §9).
  • aidecision_provenance jsonb on price_rule/price_list_assignment cites memory_refs pointing at ai.agent_memory.id (a markdown-proposing agent may read a learned seasonal-markdown pattern) and delegated_by_actor_id.

Depended on by: POS and Orders (neither built yet) via PricingService.resolvePrice() — the single hottest read path this module exists to serve. See the Hard Contracts in §7 for what's binding on those future builds. Reporting (not yet built) is expected to eventually group price_rule/price_change_log rows by campaign_label for promotion-effectiveness analysis — out of scope for this pass, but the grouping key already exists.

4. Tables

4 tables, 75 columns, locked 2026-07-06. See packages/db/migrations/20260706090000_pricing_module.sql for the full DDL. Drizzle schema files: packages/db/src/schema/pricing/{_schema,level,rule,assignment,change_log,index}.ts.

Table PK style RLS Notes
price_level UUID Enabled Human-curated catalog, no autonomy columns
price_rule UUID Enabled The workhorse; supersede-don't-edit state machine; full autonomy treatment
price_list_assignment UUID Enabled Customer/group → price_level tier assignment; full autonomy treatment
price_change_log UUID Enabled Append-only; automation_source only
Total 75 cols

All 4 tables are tenant-scoped (tenant_id NOT NULL throughout, FK → platform.tenant). All 4 have RLS enabled with a permissive tenant-isolation policy named <table>_tenant_isolation (e.g. price_rule_tenant_isolation), matching the exact naming convention live-confirmed on item_variant/site/customer/agent_duty_grant, scoped USING/WITH CHECK against current_setting('app.current_tenant_id')::uuid, plus a plain index on tenant_id. No mixed-scope (nullable tenant_id) case exists anywhere in this module, unlike ai.ai_request.

4 triggers total: set_updated_at on price_level/price_rule/price_list_assignment (not on price_change_log), plus trg_price_rule_validate_supersession on price_rule — the one genuinely new trigger function this module introduces (see DR-7).

22 CHECK constraints total: chk_price_change_log_automation_source, chk_price_change_log_change_type, chk_price_change_log_source_type, chk_price_list_assignment_automation_source, chk_price_list_assignment_customer_xor_group, chk_price_list_assignment_review_status, chk_price_list_assignment_valid_window, chk_price_rule_applies_all_sites_xor_site_id, chk_price_rule_automation_source, chk_price_rule_customer_scope_site_explicit, chk_price_rule_min_qty, chk_price_rule_no_self_supersede, chk_price_rule_price_type, chk_price_rule_price_value, chk_price_rule_price_value_integer_for_currency_amounts, chk_price_rule_review_status, chk_price_rule_reviewer_not_creator, chk_price_rule_scope_fk_consistency, chk_price_rule_status, chk_price_rule_superseded_by_consistency, chk_price_rule_tax_treatment, chk_price_rule_valid_window.

price_level (11 cols)

A named pricing tier (retail, wholesale, member), directly analogous to crm.customer_group's own established precedent: tiny, human-set-up-once reference data. Columns: id (PK), tenant_id (FK platform.tenant), code (unique per tenant), name, description (nullable), is_default (boolean, exactly one true per tenant — partial unique, mirrors multi_loc.site.is_primary), is_active, sort_order, created_at, updated_at, deleted_at. No autonomy columns at all — this is not a target of AI mutation, same reasoning as customer_group.

Indexes: PK on id; plain index on tenant_id; UNIQUE (tenant_id, code) WHERE deleted_at IS NULL; UNIQUE (tenant_id) WHERE is_default = true AND deleted_at IS NULL.

price_rule (33 cols) — the workhorse

Absorbs every pricing kind via discriminator columns (scope_type, price_type), not per-kind tables — a v1 decision carried forward unchanged. Columns: id, tenant_id, item_variant_id (FK inventory.item_variant, NOT NULL, ON DELETE RESTRICT), site_id (nullable FK multi_loc.site, ON DELETE RESTRICT — NULL means tenant-wide for default/price_level scope), applies_all_sites (boolean NOT NULL DEFAULT false), scope_type (CHECK default/price_level/customer/customer_group), price_level_id (nullable FK price_level), customer_id (nullable FK crm.customer), customer_group_id (nullable FK crm.customer_group), currency_code (char(3) NOT NULL, FK shared.currency.iso_code, ON DELETE RESTRICT — snapshotted, see DR-8), price_type (CHECK fixed_price/percent_off/amount_off/cost_plus_percent), price_value (numeric NOT NULL — see DR-3 for the currency-integer convention and the money-type deviation note), tax_treatment (text NOT NULL DEFAULT 'exclusive', CHECK inclusive/exclusive), min_qty (integer, nullable — quantity breaks), valid_from/valid_until (timestamptz, nullable), priority (integer, tiebreak), status (text NOT NULL DEFAULT 'active', CHECK active/superseded/expired), superseded_by_id (nullable self-FK → pricing.price_rule.id), campaign_label (text, nullable), reason (text, nullable), idempotency_key (text, nullable), created_by_actor_id/updated_by_actor_id (FK identity.actor), automation_source, review_status/review_reason/reviewed_by_actor_id/reviewed_at, decision_provenance (jsonb), created_at/updated_at/deleted_at.

Full autonomy treatment (see §6). Actor-attribution goes to identity.actor, never identity.identity_user. review_status is the human-in-the-loop seam: a pending row has zero live effect on resolvePrice(). status is an orthogonal state machine governing whether this row is the current version of a price (see DR-5/DR-6 for the supersede-don't-edit mechanism this replaces is_active with).

Indexes: PK; tenant_id; item_variant_id; partial on review_status WHERE pending; UNIQUE (superseded_by_id) WHERE superseded_by_id IS NOT NULL; price_rule_pending_scope_dedup_unique (see DR-13/DR-14); price_rule_resolve_price_idx (see DR-6); idempotency partial-unique (see DR-15).

price_list_assignment (19 cols)

Assigns a customer or customer group to a price_level tier. Columns: id, tenant_id, price_level_id (FK price_level, NOT NULL, ON DELETE RESTRICT), customer_id (nullable FK crm.customer), customer_group_id (nullable FK crm.customer_group — exactly one of the two set), valid_from (timestamptz NOT NULL default now()), valid_until (nullable), idempotency_key (nullable), created_by_actor_id/updated_by_actor_id, automation_source, review_status/review_reason/reviewed_by_actor_id/reviewed_at, decision_provenance, created_at/updated_at/deleted_at.

Full autonomy treatment too (unlike the static price_level catalog): assigning a customer to a price tier is a plausible agent capability, directly analogous to crm.customer_segment_membership's own "agent-computed... wholesale-like classification" precedent, so it gets the identical column set to price_rule.

CHECKs: chk_price_list_assignment_customer_xor_group, chk_price_list_assignment_valid_window, chk_price_list_assignment_automation_source/review_status.

Indexes: PK; tenant_id; partial index on customer_id WHERE NOT NULL; partial index on customer_group_id WHERE NOT NULL (both needed — resolvePrice() must find a customer's or group's assigned price level); idempotency partial-unique matching price_rule's own fixed grain (see DR-15).

Known, deliberately-deferred gap: no overlap-prevention across time-windowed assignments for the same customer/group (would need a btree_gist EXCLUDE constraint on a date range). Logged to OPEN_ITEMS, not solved now.

price_change_log (12 cols) — append-only

Audit trail of price/assignment changes. Columns: id, tenant_id, price_rule_id (nullable FK price_rule — NULL when the row is about an item_variant.base_price_cents change instead), item_variant_id (FK inventory.item_variant, NOT NULL), change_type (CHECK rule_created/rule_updated/rule_superseded/rule_deleted/rule_expired/base_price_changed — see DR-6 for why this differs from v1's enum), old_value/new_value (jsonb, nullable — see the 3 example shapes in DR-6's note), source_type (CHECK manual/import/promotion/scheduled_job/agent — the 'agent' value is a v1 deviation, absent in v1's enum), changed_by_actor_id (nullable FK identity.actor — retargeted from v1's changed_by → identity.identity_user), automation_source, reason (nullable), created_at. No updated_at/deleted_at — immutable, matches ai.agent_execution.

Gets automation_source only (cheap, lets Reporting query "how many price changes were agent-initiated") — not the full review-seam set, since the log itself is never "pending," only the row it describes is. No decision_provenance either: this table is a mechanical "what changed" record, not a decision-bearing row itself; the decision's own provenance lives on price_rule/price_list_assignment, which this log merely describes.

Net column delta vs. v1: v1's locked docs/old/schema/schema_modules/schema_pricing.md (229 lines) specified 4 tables / 56 cols. This build's delta: table count unchanged at 4, +19 columns (56→75)price_level 10→11, price_rule 21→33 (+12, net +4 after removing is_active: added applies_all_sites/tax_treatment/status/superseded_by_id/campaign_label/currency FK/etc.), price_list_assignment 12→19 (autonomy columns), price_change_log 13→12 (enum values changed, not column count — net -1 from actor-column retargeting).

5. Capabilities

  • Rule-based pricingprice_rule expresses fixed prices, percent-off, amount-off, and cost-plus-percent markups, scoped to a variant + optionally a site, price level, customer, or customer group, with quantity breaks (min_qty) and date windows (valid_from/valid_until).
  • Category/brand-scoped pricing rules (added 2026-07-20, Gap-Fill Batch A1) — a merchant (or agent) can now express "20% off all perennials" or "15% off everything from Proven Winners" as a single price_rule row, instead of one row per item_variant. item_scope_type (variant/category/brand/all) is an orthogonal WHAT-axis discriminator alongside the pre-existing scope_type WHO-axis (customer/price_level/customer_group targeting): a rule can now target a specific variant (unchanged default behavior), an entire inventory.category, an entire inventory.brand, or every item tenant-wide ('all'), each combinable with any WHO-axis scope. See DR-21 below for the naming rationale and DR-9's/Hard Contract 2's continued bearing on resolution.
  • Tier assignmentprice_list_assignment puts a customer or customer group onto a price_level, itself date-windowed.
  • Markdown/dead-stock proposals — an agent can propose a markdown as an ordinary pending price_rule row; see the full loop in §8.
  • Margin-erosion detectionprice_type='cost_plus_percent' resolves live against item_variant.avg_cost_cents, giving "cost rose, price didn't" detection a real resolution path (see DR-9).
  • Full price history — the supersede-don't-edit mechanism (DR-5) means every price a variant/scope ever had is preserved as a chain of rows, not overwritten in place.
  • Audit trailprice_change_log records every price/assignment mutation, mechanically, independent of the decision-bearing row it describes.

No PricingService exists yet — schema-only build (Drizzle + migration + tests). See §6 and §7.

6. Service Contract — PricingService

PricingService does not exist yet — schema-only this pass, matching shared/multi_loc/crm/inventory's own precedent for a module's first pass. Downstream code would query pricing.* directly via Drizzle for now, but since POS/Orders (the actual consumers) also don't exist yet, there is no live caller at all today. What IS binding, regardless of when PricingService gets built, are the 3 Hard Contracts in §7 — they constrain the shape PricingService and its Dart/Flutter counterpart MUST take when they are eventually built, not just a style suggestion.

7. Build Requirements / Service Layer Contract

The following 3 Hard Contracts are binding requirements on whoever builds PricingService, the Dart/Flutter offline resolver, and the pos/orders schemas — not soft suggestions, not deferred discussion items. They exist because independent verification found the original wording for each had a real, exploitable loophole; each is fixed below with the closed wording. None is satisfied yet (the consuming modules don't exist), and each has a corresponding OPEN_ITEMS row tracking that.

Hard Contract 1 — pos.sale_line MUST snapshot the resolved price at sale time

To be recorded as a LOCKED entry in docs/modules/CROSS_MODULE_CONTRACTS.md's existing "Pricing" section, strengthening its current language ("final price stamped on the transaction line") into this fully specified, binding requirement.

pos.sale_line (and Orders' equivalent line-item table) MUST snapshot all of the following at the moment of sale/order confirmation:

  1. resolved_amount_minor_units (bigint, NOT NULL) — the PRE-rounding resolved amount, an exact integer count of the currency's smallest unit (importing the price_value integer-for-currency-amounts convention explicitly — see DR-3).
  2. charged_amount_minor_units (bigint, NOT NULL) — the actual amount charged to the customer AFTER any PricingService display-rounding (Hard Contract 3); equal to resolved_amount_minor_units when no rounding was applied.
  3. currency_code (char(3)).
  4. tax_treatment (text).
  5. resolving_price_rule_id (nullable FK) — for traceability; NULL when no rule matched and bare base_price_cents was used.
  6. resolved_quantity — the quantity the price was resolved against, needed to verify a min_qty-tier rule applied correctly (a rule can cover multiple tiers via separate rows, so resolving_price_rule_id alone doesn't disambiguate which tier fired).

This is the authoritative historical record of "what did the customer actually pay" — distinguishing the resolved-vs-charged amount closes the "$9.99 displayed, $9.97 charged" ambiguity a 2-field pair would leave unresolved. Not satisfied by price_rule's own supersede-based history alone. Binding on whoever builds the (currently unbuilt) pos/orders schemas.

Hard Contract 2 — ONE versioned resolvePrice() spec + cross-language golden test vectors, with a named minimum coverage bar

PricingService.resolvePrice(item_variant_id, site_id, customer_id, quantity, as_of_date) — the 5-step precedence algorithm (candidate filtering → specificity → min_qty → site-specific-beats-tenant-wide → priority/created_at tiebreak, now also gating on status='active', handling cost_plus_percent's live cost join, and applying tax_treatment) MUST be specified in exactly ONE authoritative pseudocode document in this file (recorded here at lock time as the canonical reference), and BOTH the Node PricingService and the Flutter/Dart offline resolver MUST be tested against the SAME golden test-vector suite (identical inputs → identical resolved price, currency, tax_treatment, and resolving rule) before either ships.

The golden-vector suite MUST include at least one test case exercising each of the following dimensions — a minimum, not exhaustive, coverage floor naming the specific risk areas, not just "some tests":

  • Specificity ties (two candidate rules of equal scope specificity).
  • Site-specific-vs-tenant-wide precedence.
  • The priority/created_at tiebreak.
  • cost_plus_percent's live avg_cost_cents join.
  • applies_all_sites resolution.
  • At least one min_qty tier boundary (exactly at the threshold, one below, one above).

This is binding, not optional — a shallow 3-vector suite does not satisfy this contract even if it technically exercises "some" cases; every dimension above must be represented. Both implementations must pass the same suite before either ships, not just one.

Hard Contract 3 — display rounding is a PricingService concern, never a schema mutation

PricingService MAY apply a post-resolution display-rounding rule (e.g. round to nearest .99), as a configurable, per-price_level-or-per-tenant DISPLAY-LAYER transform applied to the final resolved amount shown to a customer. This must NEVER be applied to price_rule.price_value itself, which always holds the exact configured value as an integer count of the currency's smallest unit (DR-3) — this prohibition is unambiguous.

Whatever rounding rule PricingService applies, the PRE-rounding resolved amount AND the POST-rounding charged amount MUST both be captured distinctly at sale time — this is Hard Contract 1's resolved_amount_minor_units / charged_amount_minor_units pair. This contract governs WHERE rounding may be applied (display layer only, never the stored rule); Contract 1 governs that BOTH values, not just one, are preserved as the historical record. The two contracts are deliberately cross-referenced so neither can be satisfied in a way that silently violates the other.

8. The Markdown/Dead-Stock Autonomy Loop

  1. Inventory maintains stock.last_movement_at (already built) — the primary, already-documented dead-stock signal.
  2. A pricing agent (agent_duty_grant grants it pricing:price_rule:propose_markdown at authority_level='draft_only', scope_type='site' or 'tenant') scans for variants with stale last_movement_at.
  3. Agent INSERTs a price_rule row directly: automation_source='agent', review_status='pending', price_type='percent_off', reason='No stock movement in 90 days', decision_provenance={"reason":"aging stock","evidence":{"last_movement_at":...,"days_stale":90},"confidence":0.82,"memory_refs":[...]}.
  4. Agent logs one ai.agent_execution row: status='proposed', action_code='pricing:markdown:propose', target_module='pricing', target_table='price_rule', target_row_id=<the new price_rule.id> — the draft row already exists at creation time, so this is NOT the target_row_id IS NULL create-new-record case agent_execution.resolves_execution_id exists for. authority_level_applied='draft_only'.
  5. A human reviews the pending price_rule row and sets review_status='approved' + reviewed_by_actor_id/reviewed_at — this is the human action, captured on price_rule itself, not a second agent_execution row. ai.agent_execution.agent_identity_id is NOT NULL — every row in that table must attribute to a real agent, structurally, so a bare human click cannot be logged as a second agent_execution row at all, regardless of design preference. resolves_execution_id stays available for a genuine second AGENT action completing what a first agent started, not for a human's own review click. No other reviewed table in this codebase (item, item_variant, customer, site, agent_duty_grant) logs a second agent_execution row for its own human-review step either — this is established, codebase-wide precedent, not a pricing-specific exception.
  6. PricingService.resolvePrice() now includes this rule (once PricingService exists); a service hook writes a price_change_log row (change_type='rule_created', source_type='agent').

A pending markdown proposal needs no separate propose/execute staging table (unlike stock_adjustment_requeststock_movement or customer_merge_candidatecustomer_merge) — those pairs exist because approving them produces a DIFFERENT row or an IRREVERSIBLE side effect. A markdown proposal is just a price_rule row with review_status='pending' from the moment the agent creates it; resolvePrice() only considers rows where review_status IN ('not_required','approved'), so a pending row has zero live effect until a human approves it — already-solved by the same review-seam machinery item/item_variant/customer/site use. 4 tables, matching v1's count, is confirmed correct — not under- or over-tabled.

9. Agent Authority Mapping

pricing introduces no new authority mechanism — like crm, inventory, and ai before it, it is a pure consumer of identity.agent_duty_grant (PROJECT_DECISIONS #22). agent_duty_grant.authority_level applies cleanly: draft_only for markdown/price-change proposals (a price change is squarely financial — independently controlled and reversible, so never may_act_alone); may_act_alone for writing a price_change_log row once a change is approved/executed (observational, zero mutation risk, matching ai.agent_execution's own logging precedent).

The new discount/margin-ceiling gap. agent_duty_grant.spend_limit_cents/quantity_limit do not map onto "how much may this agent discount by." A markdown doesn't spend money — it reduces future revenue, a different risk dimension entirely (a discount-percent ceiling or margin floor, not a spend ceiling). This is distinct from the already-logged spend-ceiling-cumulative-tracking gap (PROJECT_DECISIONS #25, ai's build) — that gap is about summing repeated per-action spends against a cumulative cap; this one is about there being no limit dimension at all for "percent discount" or "margin floor," regardless of cumulative tracking. Not fixed in identity's schema this pass — out of scope for pricing's own design pass, and no real markdown agent exists yet to need it enforced beyond human review ("build thin"). Logged to OPEN_ITEMS, trigger: "when a markdown-proposing agent actually runs and needs an enforced discount/margin ceiling beyond human review."

10. Design Rationale (DR-1 through DR-21)

This is pricing's first module_spec entry — DR numbering starts fresh at DR-1 (no prior DR history to continue for this module).

  • DR-1 — v1 reconciliation. v1 docs: docs/old/design_rationale/rationale_pricing.md (35 lines), docs/old/schema/schema_modules/schema_pricing.md (229 lines). v1's table list (4 tables / 56 cols): price_level (10), price_rule (21), price_list_assignment (12), price_change_log (13). MODULE_INDEX.md's pricing row ("4 tables / 56 cols") numerically matched v1 exactly at design-pass start — not a stale/placeholder mismatch like crm's row was. But it was still a pre-v2 count: no currency FK (raw char(3) default 'USD', since shared.currency didn't exist at v1 design time), no agent/AI concept anywhere (price_change_log.source_type CHECK had no 'agent' value; changed_by FKs identity.identity_user directly, not an actor-polymorphic root), and one nursery-specific illustrative example string with no schema impact ("20% off all perennials this weekend"). v1.0 was explicitly tenant-wide only (site_id always NULL, deferred to v1.5 per multi_loc's own forward decision #3) — multi_loc is now locked, so this build makes site-scoping real. v1's governing decisions carried forward as-is: Pricing owns predefined policy, POS/Orders own transaction-time discounts; one price_rule table absorbs all pricing kinds via discriminator columns, not per-kind tables; precedence resolution is PricingService logic, never a DB constraint; base price source of truth stays item_variant.base_price_cents.

  • DR-2 — RLS, stated explicitly for all 4 tables (a Section 4 Item B gap in the original draft, where RLS was implied but never explicitly stated for any table). All 4 tables get tenant_id NOT NULL FK → platform.tenant, RLS enabled with a permissive tenant-isolation policy named <table>_tenant_isolation, matching the naming convention live-confirmed on item_variant/site/customer/agent_duty_grant, scoped against current_setting('app.current_tenant_id')::uuid, plus a plain index on tenant_id. No mixed-scope (nullable tenant_id) case exists anywhere in this module, unlike ai.ai_request.

  • DR-3 — the currency-integer convention (gap #3) and the money-type deviation note. A cross-table CHECK tying price_value's precision to shared.currency.decimal_places isn't expressible in Postgres without a trigger (the same limitation already documented for ai_request.tenant_id/agent_identity_id and decision_provenance.memory_refs). The actionable half of this gap IS same-row-checkable: price_value for fixed_price/amount_off/cost_plus_percent-adjacent currency amounts must always be a whole-number integer count of the currency's smallest unit — matching item_variant.base_price_cents's own established "cents" convention. This works uniformly across currencies without needing decimal_places at the CHECK level (a JPY price_value of 1500 means ¥1500 since JPY's smallest unit IS the yen; a USD price_value of 1500 means $15.00) — the difference is purely in DISPLAY (divide by 10^shared.currency.decimal_places), not in whether the stored value must be whole. percent_off/cost_plus_percent are excluded (percentages, may be fractional, e.g. 12.5%). CHECK: chk_price_rule_price_value_integer_for_currency_amounts: price_type IN ('percent_off','cost_plus_percent') OR price_value = floor(price_value). Money-type deviation note: price_rule.price_value is a bare numeric, not this project's standard _cents bigint convention — a deliberate deviation, since one column must hold both currency amounts (integer smallest-unit counts) AND percentages (fractional). This mirrors shared's own precedent for a documented, deliberate deviation from a project-wide default (PROJECT_DECISIONS #17, the natural-key-PK exception) — recorded here so it gets the same formal deviation-note treatment at lock time, not left as informal prose only.

  • DR-4 — tax_treatment (gap #4). Every price_rule row now states explicitly whether its price_value is tax-inclusive or tax-exclusive, closing a "global from day 1" gap (EU/UK/AU convention is tax-inclusive display; US is tax-exclusive) that had zero schema seam anywhere in the codebase. Defaults to 'exclusive' (current US-first reality) but is fully overridable per rule — different rules for the same tenant could legitimately carry different treatments (e.g. a negotiated B2B quote stated tax-exclusive even in an otherwise tax-inclusive jurisdiction). CHECK: chk_price_rule_tax_treatment (IN inclusive/exclusive). Known, explicitly-flagged residual gap: inventory.item_variant.base_price_cents (the fallback when no price_rule matches) has NO tax_treatment concept at all — when resolution falls through to the bare base price, its tax treatment is undefined at the schema level. Out of scope for this pass to fix unilaterally in Inventory's schema; logged to OPEN_ITEMS for whoever builds a Tax module or revisits item_variant.

  • DR-5 — the supersede-don't-edit mechanism (gap #1, the #1 finding cited by all 3 independent outside critics). price_value/price_type/currency_code are now never mutated in place. A price change = a brand-new row + the old row's status flipped to superseded with superseded_by_id pointing at the new row. is_active is removed entirely, replaced by status (CHECK active/superseded/expired). Two ORTHOGONAL state machines now coexist on price_rule: review_status governs whether a human has approved a row for resolution; status governs whether THIS row is the current version of a price or has been replaced/aged out. A newly-created draft starts status='active' + review_status='pending' — independent dimensions, not a combined lifecycle. The write path for a genuine price change: INSERT a new row (fresh id, new values, status='active'), then UPDATE the OLD row's status='superseded' + superseded_by_id = <new row's id> — the old row's price fields themselves are never touched, only its lifecycle-tracking columns. status='expired' is an optional, sweep-driven bookkeeping state (a scheduled job MAY flip a row past its valid_until); resolution correctness never depends on the sweep having run, since resolvePrice() always filters on valid_from/valid_until directly regardless of status. Reversing a bad change (D10): either soft-delete via deleted_at (pulled, no replacement) or supersede with a corrected row (the normal mechanism) — is_active toggling no longer exists as a third option. CHECK: chk_price_rule_superseded_by_consistency: (status = 'superseded' AND superseded_by_id IS NOT NULL) OR (status != 'superseded' AND superseded_by_id IS NULL). Index: UNIQUE (superseded_by_id) WHERE superseded_by_id IS NOT NULL (mirrors ai.agent_execution.resolves_execution_id's own uniqueness precedent — a successor row can only be claimed by exactly one predecessor).

  • DR-6 — price_change_log.change_type, re-derived for the supersede-don't-edit model. With is_active gone and price fields never mutated in place, v1's/round-1's enum (rule_created/rule_updated/rule_deactivated/rule_reactivated/rule_deleted/rule_expired/base_price_changed) needed revision: rule_reactivated no longer corresponds to any real transition (there is no "un-supersede"/"un-expire" — bringing a price back is naturally a fresh rule_created row, or a rule_superseded event if it explicitly replaces an expired/deleted one), and rule_deactivated is redundant with rule_deleted now that is_active is gone (both describe "pulled, no replacement" — collapsed into one). A new value, rule_superseded, covers "this row was replaced by a successor" (previously conflated with rule_updated, which now means only non-price-field edits — reason, priority, campaign_label, or a review_status transition). Final: change_type IN ('rule_created','rule_updated','rule_superseded','rule_deleted','rule_expired','base_price_changed') (6 values). Example shapes (closing a round-2-verification gap where only 1 of 6 values had an illustrated shape): rule_createdold_value=NULL, new_value={"price_type":"percent_off","price_value":15,"status":"active","currency_code":"USD"}; rule_supersededold_value={"price_type":"percent_off","price_value":15,"status":"active"}, new_value={"price_type":"percent_off","price_value":20,"status":"active","superseded_row_id":"<old row's id>"}; base_price_changedold_value={"base_price_cents":2999}, new_value={"base_price_cents":2499} (about item_variant.base_price_cents, not a price_rule field — price_rule_id is NULL for this change_type).

  • DR-7 — two supersession-cycle bugs found by adversarial verification, both fixed. (1) Nothing prevented superseded_by_id from being set to the row's OWN id (a trivial self-reference, passing the NULL-ness CHECK alone). Fixed with chk_price_rule_no_self_supersede: superseded_by_id IS NULL OR superseded_by_id <> id. (2) A longer cycle (row A's superseded_by_id=B, row B's superseded_by_id=A, both status='superseded') satisfies every other CHECK/index independently (the UNIQUE (superseded_by_id) index sees two distinct target values, not a collision) and would infinite-loop any "walk to the current version" query. This is not reachable through the intended write path (create the new row first, which has superseded_by_id IS NULL by construction, then point the OLD row at the new row's id — a genuinely fresh row can never already be part of a chain), so a cycle can only occur via a bug or deliberate misuse outside the correct two-step write pattern. Judged disproportionate for a general cycle-detector trigger (a state structurally unreachable through the correct write path) — documented instead as a binding write-path discipline: whoever builds PricingService's write path MUST always (a) INSERT the new row first with no superseded_by_id reference to it from anywhere, then (b) UPDATE only the ONE specific predecessor row being replaced — never manually re-point an already-superseded row's superseded_by_id, never chain more than one hop per write.

    The most severe finding this round — superseded_by_id cross-tenant/variant/scope leak. A bare self-FK with no composite constraint tying the target's tenant_id/item_variant_id/scope_type to the row being replaced is exploitable via an ordinary application bug, not just malice: a cross-TENANT supersession would let an audit/history JOIN leak another tenant's pricing data — precisely the class of leak DR-2's RLS section otherwise exists to prevent. A same-row CHECK cannot express this (it requires comparing against a DIFFERENT row), so — unlike every other cross-table concern this design deferred to documentation — this one gets a real trigger, because the risk is a live RLS-adjacent data leak, not a hypothetical caller:

    CREATE FUNCTION pricing.trg_price_rule_validate_supersession() RETURNS trigger AS $$
    BEGIN
      IF NEW.superseded_by_id IS NOT NULL THEN
        IF NOT EXISTS (
          SELECT 1 FROM pricing.price_rule
          WHERE id = NEW.superseded_by_id
            AND tenant_id = NEW.tenant_id
            AND item_variant_id = NEW.item_variant_id
            AND scope_type = NEW.scope_type
        ) THEN
          RAISE EXCEPTION 'price_rule.superseded_by_id must reference a row with the same tenant_id, item_variant_id, and scope_type';
        END IF;
      END IF;
      RETURN NEW;
    END;
    $$ LANGUAGE plpgsql;
    
    CREATE TRIGGER trg_price_rule_validate_supersession
      BEFORE INSERT OR UPDATE OF superseded_by_id ON pricing.price_rule
      FOR EACH ROW EXECUTE FUNCTION pricing.trg_price_rule_validate_supersession();
    

    This is the one new trigger this module introduces: fires BEFORE INSERT/UPDATE, only when superseded_by_id is actually being set, enforcing a same-row-shape invariant a CHECK cannot express. Live-tested: a legitimate same-tenant/variant/scope supersession succeeds; a cross-tenant supersession attempt is rejected by this trigger.

  • DR-8 — currency_code, reversing the original draft's "no currency column" decision. Adversarial verification found: deriving currency live from item_variant.currency_code is "always resolvable" but not "always correct historically" — that column is a live, updatable NOT NULL FK, not frozen at insert time. If it changes after a price_rule exists, the rule's price_value would be silently reinterpreted under the new currency with zero audit trail — worse than v1's static column in this one scenario. Fix: currency_code is a real NOT NULL FK → shared.currency.iso_code, populated from item_variant.currency_code at INSERT time and never updated thereafter — a point-in-time snapshot, not a live derivation, mirroring agent_execution.authority_level_applied's own established snapshot-not-live-reference pattern. Not a reversion to v1's gap (v1's column had no FK and a hardcoded 'USD' default; this one is FK-enforced and populated from the real variant at creation time) — closes the time-stability gap while keeping the currency ISO-4217-governed.

  • DR-9 — price_type='cost_plus_percent' (gap #5), design plus its later bound-fix. A 4th price_type: price_value is interpreted as a markup percentage over item_variant.avg_cost_cents (e.g. price_value=40 → resolved price = avg_cost_cents × 1.40). This is the mechanism actually backing the "margin-erosion detection reuses price_rule in reverse" capability — without it, that claim had no real resolution path. Critical behavioral requirement: unlike fixed_price/percent_off/amount_off (resolved against a relatively stable anchor), cost_plus_percent MUST be resolved by joining item_variant.avg_cost_cents LIVE at resolution time, never cached/snapshotted — the entire point of this price_type is to track cost fluctuations dynamically. Documented as a binding PricingService requirement (Hard Contract 2, §7).

    The bound-fix. The original branch only required price_value>0 (reusing percent_off's bound structure), which meant (a) a 150%+ "markup" inserted cleanly with zero ceiling — a fat-finger of 4000 vs. 40, or a runaway agent, had no DB-level backstop, and (b) there was no way to express "sell at cost" (0% markup) or a deliberate below-cost clearance markdown, directly undercutting this price_type's own stated purpose. Fixed: price_value BETWEEN -100 AND 1000 — negative values down to -100 express a markdown below cost (e.g. -20 = 20% below avg_cost_cents, floored at -100 = free/$0, never negative-priced), 0 expresses selling exactly at cost, and the 1000 ceiling (10x cost) is a deliberately generous but bounded guard against absurd values while still accommodating legitimately high-markup nursery specialty items. This specific ceiling is a business-policy judgment call, not an objectively "correct" number — flagged explicitly as revisitable, not settled. Live-tested: cost_plus_percent = -50 (a below-cost clearance markdown) succeeds; cost_plus_percent = 1500 (above the ceiling) is rejected by chk_price_rule_price_value. Full CHECK: chk_price_rule_price_value: (price_type='fixed_price' AND price_value>=0) OR (price_type='percent_off' AND price_value>0 AND price_value<=100) OR (price_type='amount_off' AND price_value>0) OR (price_type='cost_plus_percent' AND price_value>=-100 AND price_value<=1000).

  • DR-10 — applies_all_sites (gap #2), closing the site-scoping/privacy-blast-radius gap. The outside critique found: a customer/customer_group-scoped rule with site_id=NULL silently meant "visible/synced everywhere, for this customer, at every site" — including sites this specific customer may never visit, a real privacy/blast-radius risk for a lost/stolen Flutter POS tablet (it would carry readable negotiated pricing for customers unrelated to that location). Fixed: for scope_type IN ('customer','customer_group'), the author (human or agent) must make an EXPLICIT choice — either pin site_id to a specific site, or explicitly set applies_all_sites=true. default/price_level-scoped rules are unaffected (their own site_id=NULL already unambiguously means tenant-wide with no customer-identity/privacy stakes). CHECK: chk_price_rule_customer_scope_site_explicit: scope_type NOT IN ('customer','customer_group') OR site_id IS NOT NULL OR applies_all_sites = true. Live-tested: applies_all_sites=true AND site_id both set on a customer-scoped row is rejected — see the next fix.

    Round-2-verification fix — applies_all_sites + site_id set simultaneously was silently ambiguous. Nothing prevented BOTH being set at once, and the documented sync query only credited applies_all_sites when site_id IS NULL — a row with both set would silently behave as site-only, with applies_all_sites=true becoming a dead, misleading flag exactly when a form or agent might default both fields together. Fixed with chk_price_rule_applies_all_sites_xor_site_id: applies_all_sites = false OR site_id IS NULL — exactly one of "pinned to a specific site" or "explicitly all-sites" can be true, never both.

    The sync query must also gate on review_status, closing a pending-draft replication leak. The original sync query had no review_status filter — a PENDING, not-yet-approved, agent-authored row with applies_all_sites=true would replicate to every POS device tenant-wide via sync, independent of whether resolvePrice() itself ever prices it. This reopens exactly the risk applies_all_sites exists to close, at the sync/replication layer even though the pricing layer stays correctly gated by review_status. Fixed by making the gate explicit in the documented sync contract: Flutter's sync query is WHERE (status='active' AND review_status IN ('not_required','approved') AND deleted_at IS NULL) AND ((site_id = :site) OR (site_id IS NULL AND scope_type NOT IN ('customer','customer_group')) OR (site_id IS NULL AND applies_all_sites = true)) — customer-specific rules only sync to a device if pinned to that site or explicitly declared all-sites, AND only once approved, never while still pending.

    Build-requirement note (documented, not DB-enforced — no CHECK can express "provenance mentions X"): when an agent sets applies_all_sites=true on a customer/customer_group-scoped row, decision_provenance MUST include an explicit justification for the all-sites declaration — closing a reviewer-inattention risk: a reviewer's attention naturally focuses on an eye-catching price/discount change, and a bundled applies_all_sites=true flag (defaulting to false, only meaningful inside one CHECK branch) could ship in the same approval click without being specifically scrutinized unless the provenance explicitly calls it out.

  • DR-11 — campaign_label (gap #6), a lightweight tag deliberately NOT a full Promotion entity. The outside critique separately flagged the absence of a full promotion/campaign header object (usage caps, redemption limits, stacking rules, coupon codes) the way Shopify Discounts/Magento Cart Price Rules/SFCC Promotions have. That is a genuinely bigger feature than what's resolved here and stays correctly out of scope (an OPEN_ITEMS candidate, not silently dropped). campaign_label (text, nullable) is a cheap, informal grouping tag closing the narrower gap: Reporting can group price_rule/price_change_log rows by a human-assigned label (e.g. "spring_sale_2026") instead of fuzzy-matching free-text reason strings, and an agent proposing a batch of markdowns can tag them all under one campaign for later analysis — without inventing usage caps, coupon codes, or a stacking-rules engine this pass.

  • DR-12 — chk_price_rule_scope_fk_consistency, written as fully explicit SQL after a live-confirmed bug in the prose form. The Section 4 auditor live-tested that a "each branch pins only its own FK" reading (the ambiguous form the first draft's prose implied) lets a scope_type='price_level' row ALSO set customer_id — a leaked second FK, live-confirmed as a real bug (INSERT 0 1 succeeded when it should have failed). Fixed by pinning ALL THREE optional FKs in every branch:

    CHECK (
      (scope_type = 'default' AND price_level_id IS NULL AND customer_id IS NULL AND customer_group_id IS NULL)
      OR (scope_type = 'price_level' AND price_level_id IS NOT NULL AND customer_id IS NULL AND customer_group_id IS NULL)
      OR (scope_type = 'customer' AND price_level_id IS NULL AND customer_id IS NOT NULL AND customer_group_id IS NULL)
      OR (scope_type = 'customer_group' AND price_level_id IS NULL AND customer_id IS NULL AND customer_group_id IS NOT NULL)
    )
    

    Live-verified: correctly rejects a customer-scoped row that also sets customer_group_id; correctly rejects default-scoped rows with any FK set; correctly requires the matching FK for its own scope. Does NOT forbid customer_id + site_id set together — adversarial verification confirmed site_id sits outside this discriminator entirely, a legitimate combination (a customer-specific rule can still be site-specific).

  • DR-13 — chk_price_rule_reviewer_not_creator, closing a C8 "independently controlled" enforcement gap. Nothing previously prevented the SAME actor from both creating and approving a draft price change — a real segregation-of-duties gap in C8's "independent approval" half, unflagged in the original draft. Fixed with a CHECK that only fires when a row actually has both fields set (human-authored rows with review_status='not_required' leave reviewed_by_actor_id NULL and are unaffected): CHECK (reviewed_by_actor_id IS NULL OR created_by_actor_id IS NULL OR reviewed_by_actor_id <> created_by_actor_id).

  • DR-14 — the dedup partial-unique index, and its two round-2 fixes (min_qty, price_type). A naive UNIQUE (tenant_id, item_variant_id, site_id, scope_type, price_level_id, customer_id, customer_group_id) WHERE review_status='pending' would not catch two duplicate tenant-wide pending proposals for the same variant (Postgres treats NULL as distinct), so every nullable FK dimension is normalized via COALESCE(col, '00000000-0000-0000-0000-000000000000'::uuid). The Section 4 auditor empirically live-tested this exact expression index and confirmed it works: two tenant-wide pending proposals collide; two different-customer-scoped proposals don't; a site-scoped and a tenant-wide proposal don't.

    Fix 1 — min_qty was missing. Adversarial verification found the index omitted min_qty, so a legitimate 3-tier quantity-break ladder (e.g. min_qty=10, min_qty=25, min_qty=50, all pending, same variant/scope) would collapse to one key and the 2nd/3rd rows would be falsely rejected as duplicates. Fixed by adding COALESCE(min_qty, 0) to the key — same-quantity duplicates still correctly collide; different quantity tiers no longer falsely collide.

    Fix 2 — price_type was missing. Adversarial verification found the key also omitted price_type, so a cost_plus_percent markup proposal and an unrelated fixed_price proposal for the exact same (tenant_id, item_variant_id, scope_type, site_id, price_level_id, customer_id, customer_group_id, min_qty) produced an identical dedup key and falsely collided — the second, semantically different proposal was rejected as a "duplicate" when it wasn't one. Adding price_type to the key lets two genuinely different kinds of proposals for the same scope coexist as pending, while still catching true duplicates (same scope AND same price_type). Final form (also gated AND status = 'active' — a superseded row, which shouldn't normally carry review_status='pending' but defensively, no longer counts toward "is there already a pending proposal for this scope"):

    CREATE UNIQUE INDEX price_rule_pending_scope_dedup_unique ON pricing.price_rule (
      tenant_id, item_variant_id, scope_type, price_type,
      COALESCE(site_id, '00000000-0000-0000-0000-000000000000'::uuid),
      COALESCE(price_level_id, '00000000-0000-0000-0000-000000000000'::uuid),
      COALESCE(customer_id, '00000000-0000-0000-0000-000000000000'::uuid),
      COALESCE(customer_group_id, '00000000-0000-0000-0000-000000000000'::uuid),
      COALESCE(min_qty, 0)
    ) WHERE review_status = 'pending' AND status = 'active' AND deleted_at IS NULL;
    

    Live-tested: two DIFFERENT pending price_types (fixed_price + cost_plus_percent) for the identical scope both coexist (dedup index correctly does not collide them); a SECOND pending fixed_price proposal for the exact same scope as an existing pending fixed_price is correctly rejected as a genuine duplicate.

  • DR-15 — the idempotency_key grain, corrected to include the actor dimension. Adversarial verification found the original (tenant_id, idempotency_key) grain omits the actor dimension ai.agent_execution's own idempotency precedent scopes by (tenant_id, agent_identity_id, idempotency_key) — two DIFFERENT agents deriving the same business-meaningful key for the same variant would spuriously collide under the original grain. Fixed: UNIQUE (tenant_id, created_by_actor_id, idempotency_key) WHERE idempotency_key IS NOT NULL AND deleted_at IS NULL (a single partial-unique — tenant_id is NOT NULL throughout pricing, no two-partial split needed unlike ai_request's platform-level nullable-tenant case). Applied identically to price_list_assignment's own idempotency_key.

  • DR-16 — the hot-path resolution index, updated for the status state machine. Supports resolvePrice()'s actual query shape — the module's single hottest read path (every POS/Orders line-item price resolution) — replacing the removed is_active column:

    CREATE INDEX price_rule_resolve_price_idx ON pricing.price_rule (tenant_id, item_variant_id, site_id)
      WHERE status = 'active' AND review_status IN ('not_required','approved') AND deleted_at IS NULL;
    
  • DR-17 (added 2026-07-07, pricing reopen) — price_rule.rule_kind ('standard'/'sale'/'scheduled', default 'standard') and price_rule.name (nullable text) restored, along with price_rule_sale_expiry_idx (partial index on valid_until WHERE rule_kind='sale' AND status='active' AND deleted_at IS NULL). Found by the 2026-07-07 crm/inventory/pricing erosion audit: both were dropped entirely during the v1→v2 build with zero OPEN_ITEMS/PROJECT_DECISIONS trace. rule_kind classified a rule's TYPE independent of its date window and backed a sale-expiry sweep job in v1 — campaign_label is a free-text promo tag, not a type classification, and cannot substitute for either capability. name was v1's human display label (e.g. "Wholesale tray price") — campaign_label/reason are semantically different (grouping tag / justification) and were never real substitutes. Both were confirmed genuine, unlogged erosions by the audit's adversarial pass, independently re-verified live (CHECK rejects invalid rule_kind values by exact constraint name; the index exists with the exact claimed WHERE clause; name accepts both NULL and a real string). See PROJECT_DECISIONS #28.

  • DR-18 (added 2026-07-07, pricing reopen) — price_list_assignment.is_active (boolean, default true) restored along with two partial-unique indexes guaranteeing at most one open-ended (valid_until IS NULL), active, non-deleted assignment per customer_id and per customer_group_id. Found by the same audit: v1 had this column plus the equivalent uniqueness guarantee; v2 dropped both, leaving nothing to stop two conflicting open-ended active assignments for the same customer/group from coexisting — a real correctness risk for wholesale tier assignment. This is the most safety-critical of the pricing reopen's three restorations — independently break-it-verified: a second open-ended active assignment for the same customer/group is rejected by the exact named index; a closed-ended row or an is_active=false row does not conflict; soft-deleting the original correctly frees the uniqueness slot without opening a gap for a third live conflicting row; the pre-existing chk_price_list_assignment_customer_xor_group CHECK still holds against both NULL/NULL and SET/SET edge cases. See PROJECT_DECISIONS #28.

  • DR-19 (added 2026-07-08, Remediation Plan Phase 1) — price_rule.chk_price_rule_approved_requires_reviewer and price_list_assignment.chk_price_list_assignment_approved_requires_reviewer added, same shape as the equivalent billing/inventory instances added in the same cross-cutting pass. Both CHECKs enforce review_status != 'approved' OR reviewed_by_actor_id IS NOT NULL — closing a gap where a row could be marked approved with no reviewer ever attributed. No column or table count change (4 tables, 78 cols unchanged). See PROJECT_DECISIONS #37.

  • DR-20 (added 2026-07-08, Remediation Plan Phase 2) — price_change_log.id's DEFAULT changed from gen_random_uuid() to platform.uuid_generate_v7(). price_change_log is this module's append-only event log; UUIDv7 is time-ordered, keeping future time-range partitioning possible on this ledger without a PK rewrite — impossible once data has landed on a random UUIDv4 PK. DEFAULT-only change, no column/table count impact (4 tables, 78 cols unchanged). See PROJECT_DECISIONS #38.

  • DR-21 (added 2026-07-20, Gap-Fill Batch A1) — item_scope_type vs. scope_type, and why item-scope resolution precedence is deferred to PricingService rather than schema-enforced. The gap-fill task's own originally-proposed column name for category/brand-scoped rules was scope_type — but price_rule.scope_type already exists, governing an entirely different axis: WHO a rule targets (default/price_level/customer/customer_group, enforced by chk_price_rule_scope_fk_consistency, DR-12). Reusing the name for WHAT a rule prices (variant/category/brand/all) would have collided two independent discriminators into one column with two unrelated meanings — a real ambiguity, not a cosmetic naming quibble, since a single price_rule row already varies independently along both axes (e.g. a customer_group-scoped rule that also happens to be category-scoped). Caught as a live collision against the actual schema before any migration was written (not improvised around), and resolved by explicit architect ruling: a new, distinct column, item_scope_type, keeps the two axes independently readable, each with its own CHECK (chk_price_rule_item_scope_type, chk_price_rule_item_scope_fk_consistency) that cannot collide with the other's. This is the same "orthogonal state machine" discipline DR-5 already established for review_status vs. status on this same table — a recurring pattern on price_rule specifically: when a new dimension is added, it gets its own column and its own CHECK rather than overloading an existing one.

    Why resolution precedence (variant > category > brand > all) is documented, not schema-enforced. When a sale line could match more than one scope type for the same item (e.g. a variant-specific rule AND a category-wide rule both active for the same product), which one wins is a PricingService.resolvePrice() precedence rule, not a DB constraint — this is the same governing decision DR entries throughout this file already apply to the WHO-axis precedence question (§2's "Precedence-resolution logic... is PricingService application logic, not a DB constraint," carried forward from v1 and reaffirmed by Hard Contract 2 in §7). It is not schema-enforced for the same reason the WHO-axis precedence isn't: no PricingService exists yet to enforce it against, and expressing "the more specific of several simultaneously-valid rows wins" is inherently a resolution-time, not insert-time, concern — nothing about inserting a category-scoped row is invalid merely because a variant-scoped row might also apply to the same line at resolution time. The precedence order itself (variant > category > brand > all — most-specific-wins, matching how scope_type's own specificity ordering already works) is recorded as a documented requirement on item_scope_type (see its column comment in the schema) and folds into Hard Contract 2's existing "ONE versioned resolvePrice() spec + golden test vectors" obligation (§7) — whoever builds PricingService must add item-scope precedence as a named dimension in that same golden-vector suite, not invent a separate spec for it. Logged to OPEN_ITEMS as part of Hard Contract 2's existing open item, not a new standalone item.

11. Part D — AI Capability Discovery Walk

Each of the 15 Part D questions, reasoned individually — not bundled into a catch-all. (D1, D5, and D15 were bundled without individual reasoning in the original draft; this was flagged as a defect during verification and each is separately reasoned below.)

D1 — Capture targets. What transactions get created here: (a) the markdown-proposal loop — agent-authored price_rule rows, review_status='pending' (§8); (b) human-authored price_rule/price_list_assignment rows — a merchandiser manually setting up a wholesale rule or assigning a customer to a tier. Both are already fully covered by the schema (autonomy columns on price_rule/price_list_assignment) — no additional capture-target schema needed beyond what's already designed.

D2 — Routing. Considered, ruled out: no distinct pricing-specific routing answer beyond the markdown loop itself — no smart-table placement concern unique to pricing beyond what D1/D6 already cover.

D3 — Maintenance. Considered, ruled out: no master-data-hygiene concern unique to pricing beyond what D1/D6 already cover (no equivalent of, say, a stale-catalog-entry sweep).

D4 — Error-prevention. Selling below cost (price_rule resolving below item_variant.avg_cost_cents) is a real risk but requires a cross-table comparison Postgres CHECK cannot express without a trigger. Deferred to PricingService/OPEN_ITEMS, consistent with this project's established precedent for identical cross-table-CHECK limitations (ai_request.tenant_id/agent_identity_id, decision_provenance.memory_refs). Not left as a loose "wait and see" — bound to Hard Contract 2/3's framing and the corresponding OPEN_ITEMS trigger tied to PricingService's actual build.

D5 — Negative-space. What record should have a sibling and doesn't: every price_rule mutation should have a corresponding price_change_log row — this is the same invariant named under D9 below (cross-referenced, not a separate new answer, but not left unanswered either).

D6 — Decision-support. Markdown proposals — ruled IN, full schema investment (§8). Margin-erosion detection (cost rose, price didn't) — ruled IN, and this build backs it with a real mechanism: price_type='cost_plus_percent' resolves live against item_variant.avg_cost_cents (DR-9), closing the gap where the original design claimed this capability without a schema path to actually express it. Competitive/demand-based suggestions — ruled OUT (no external competitor/demand data source exists anywhere in v2 yet — out of scope, "build thin"). Promotion-effectiveness analysis — ruled OUT (a Reporting/Analytics concern over existing price_rule/price_change_log data, not a Pricing schema need), though campaign_label (DR-11) now gives it a clean grouping key to join on when that analysis is eventually built.

D7 — Autonomy boundary (the load-bearing question). Markdown/price-change proposals: draft_only, never may_act_alone — a price change is squarely financial (independently controlled + reversible under C8). Writing a price_change_log row once a change is approved/executed: may_act_alone (observational, zero mutation risk, matching ai.agent_execution's own logging precedent). See §9 for the full agent_duty_grant mapping.

D8 — Evidence sources. inventory.stock.last_movement_at is the primary, already-documented dead-stock signal ("gives Pricing/Reporting a cheap dead-stock/aging signal"). Abuse risk: manipulating it requires forging real stock_movement rows — Inventory's risk surface, not new here.

D9 — Reconciliation pairs. Every price_rule mutation should produce a corresponding price_change_log row, now including rule_superseded events. Not DB-trigger-enforced (matches ai.agent_execution's own "service writes the ledger" pattern, not a trigger-driven audit log) — logged as a build requirement for whoever writes PricingService.

D10 — Failure/rollback. Revised for the supersede-don't-edit model: reversing a bad price change is either (a) soft-delete via deleted_at (pulled, no replacement), or (b) supersede with a corrected row (status='superseded' + superseded_by_id, the normal price-change mechanism itself). is_active toggling no longer exists as a third option, since it's been replaced by these two more precise mechanisms. No destructive side effects on other tables either way (unlike a merge), so no dedicated cross-table reversal mechanism is needed.

D11 — Adversarial surface. The human review gate (review_status) is the primary defense against a bad autonomous price change reaching customers. This build also closes a narrower adversarial gap: a customer/customer_group-scoped rule can no longer silently become tenant-wide-visible by omission (DR-10/applies_all_sites) — an author must now explicitly declare that intent.

D12 — Offline behavior. POS (the only offline-first module) is expected to pull down an applicable price-rule snapshot for offline resolution; no schema change needed on Pricing's side.

D13 — Channel sync. Single source of truth (price_rule), read by POS/Orders/any future channel; no new schema. See DR-10 for the sync query's review_status gate.

D14 — Lifecycle/perishability. Core to this module: valid_from/valid_until on both price_rule and price_list_assignment already capture rule/assignment expiry.

D15 — Capture modality. N/A — pricing rules are structured data entry (agent-computed from stock.last_movement_at, or human form input), not a scan/photo/voice capture concern; no such pathway exists or is needed here.

12. Autonomy Schema-Translation (per table)

  • price_level — human-curated, rarely-changing catalog (retail/wholesale/member), directly analogous to crm.customer_group's own established precedent: no autonomy columns at all, same reasoning (tiny, human-set-up-once reference data, not a target of AI mutation).
  • price_rule — full autonomy treatment. Actor-attribution → identity.actor (never identity.identity_user). Autonomy metadata: automation_source. Human-in-the-loop seam: review_status/review_reason/reviewed_by_actor_id/reviewed_at — a pending row has zero live effect, so this alone satisfies D7's draft-only requirement without a separate proposal table. State machine: review_status CHECK-enumerated as usual; the separate status (active/superseded/expired) state machine is CHECK-enumerated and, unlike a boolean is_active toggle, is supersede-don't-edit rather than a reversible in-place flip (DR-5). Decision provenance: decision_provenance jsonb, citing stock.last_movement_at as evidence for markdown proposals, extended with memory_refs/delegated_by_actor_id keys per the now-real ai.agent_memory target. Agent/tenant memory: a markdown-proposing agent MAY read ai.agent_memory (e.g. a learned seasonal-markdown pattern) — recorded via decision_provenance.memory_refs, no new column.
  • price_list_assignment — full autonomy treatment too (unlike the static price_level catalog): assigning a customer to a price tier is a plausible agent capability, directly analogous to crm.customer_segment_membership's own "agent-computed... wholesale-like classification" precedent, so it gets the identical column set as price_rule.
  • price_change_log — append-only ledger, matches ai.agent_execution's own append-only shape (no updated_at/deleted_at). Gets automation_source only (cheap, lets Reporting query "how many price changes were agent-initiated") — not the full review-seam set, since the log itself is never "pending," only the row it describes is. Decision provenance and agent memory are both explicitly N/A for this table: it is a mechanical "what changed" record, not a decision-bearing row itself (the decision's own provenance lives on price_rule/price_list_assignment, which this log merely describes), and it does not consume memory itself.

13. Deferred / Future Items

All items tracked in docs/open-items/OPEN_ITEMS.md, attributed to pricing (PROJECT_DECISIONS #26). Summary for context:

Item Status Trigger
agent_duty_grant discount/margin-ceiling gap open spend_limit_cents/quantity_limit don't map onto "how much may this agent discount by." Trigger: when a markdown-proposing agent actually runs and needs an enforced discount/margin ceiling beyond human review.
No overlap-prevention EXCLUDE constraint on price_list_assignment deferred Would need btree_gist for time-windowed customer/group assignments. Trigger: if/when overlapping assignment periods become a real support burden.
Full promotion/campaign header object (usage caps, coupon codes, stacking rules) deferred campaign_label is a lightweight tag only. Trigger: when coupon-code or usage-capped promotions become a real product requirement.
tax_treatment's residual gap at the bare item_variant.base_price_cents fallback path open Tax treatment is undefined at the schema level when no price_rule matches. Trigger: when a Tax module is built or item_variant is revisited.
Hard Contract 1 (pos.sale_line snapshot requirement) open, not yet satisfied pos/orders don't exist yet. Trigger: when pos/orders are built — must include resolved_amount_minor_units/charged_amount_minor_units/currency_code/tax_treatment/resolving_price_rule_id/resolved_quantity.
Hard Contract 2 (shared resolvePrice() spec + golden test vectors with the named minimum coverage bar) open, not yet satisfied Neither PricingService nor the Dart resolver exist yet. Trigger: when Node PricingService and/or the Dart offline resolver are first built — this requirement, including its minimum coverage bar, must be satisfied before either ships, not just one.
Hard Contract 3 (display rounding is a PricingService-only concern) open, not yet satisfied Trigger: when PricingService's display layer is built.
PricingService (service layer) open No PricingService exists yet — schema-only this pass, same pattern as every other module's deferred service layer. Trigger: when PricingService is built.

Note, not an OPEN_ITEMS row: price_value numeric vs. the project's _cents bigint money convention is a deliberate, now-recorded deviation (mirrors shared's own PK-type deviation, PROJECT_DECISIONS #17) — already resolved by being recorded, not a deferred action item (see DR-3).

14. Cross-Module Seams

Seams are cataloged in docs/modules/CROSS_MODULE_CONTRACTS.md (referenced, not restated here). Key relationships:

  • pricing → inventory: price_rule.item_variant_id, price_change_log.item_variant_idinventory.item_variant.id (enforced, ON DELETE RESTRICT). cost_plus_percent-typed rules additionally resolve live against item_variant.avg_cost_cents at resolution time (DR-9) — a read-time join, not an FK.
  • pricing → multi_loc: price_rule.site_idmulti_loc.site.id (enforced, nullable, ON DELETE RESTRICT).
  • pricing → crm: price_rule.customer_id/customer_group_id, price_list_assignment.customer_id/customer_group_idcrm.customer.id/crm.customer_group.id (enforced, nullable). Customer-specific and group price-list assignments resolved via CRM identity.
  • pricing → shared: price_rule.currency_codeshared.currency.iso_code (enforced) — snapshotted at INSERT time, never updated thereafter (DR-8).
  • pricing → identity: every *_by_actor_id/changed_by_actor_id column → identity.actor.id (enforced). No new authority mechanism — pure consumer of identity.agent_duty_grant (§9).
  • pricing → ai: decision_provenance.memory_refs (documented on price_rule/price_list_assignment) resolves to ai.agent_memory.id — a plain-UUID reference inside a JSONB array, not an enforced FK, the same pattern decision_provenance uses everywhere.
  • POS/Orders → pricing (INBOUND, consumed not built here): PricingService.resolvePrice() at checkout / order confirmation — line-item price resolution. See §7's 3 Hard Contracts, binding on whoever builds pos.sale_line/Orders' equivalent and PricingService itself. This strengthens CROSS_MODULE_CONTRACTS.md's existing "Pricing" section language ("final price stamped on the transaction line") into the fully specified Hard Contract 1.

15. v1 Exclusions Re-Confirmed

v1's schema_pricing.md explicitly scoped OUT "tiered / matrix pricing" as a known limitation. This build's price_type enum grew from v1's 3 values to 4 (fixed_price/percent_off/amount_off/cost_plus_percent, the last added to resolve gap #5 — DR-9) but does not add matrix/tiered-grid pricing — still out of scope for this pass, not silently dropped. min_qty-based quantity breaks (a single threshold per rule, multiple rules for multiple thresholds) remain the only "tiering" mechanism, matching v1 exactly. The full promotion/campaign header object (usage caps, coupon codes, stacking rules) also remains correctly out of scope — campaign_label (DR-11) is a lightweight tag, not that larger feature; logged to OPEN_ITEMS as a future candidate, not silently conflated with what was resolved here.

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