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 itself —
inventory.item_variant.base_price_centsis the source of truth.pricingonly layers rules on top; aprice_rulemiss 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_rulecandidates wins isPricingServiceapplication 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_labelis a lightweight grouping tag only, not that larger feature (see DR-11). - Tax computation —
price_rule.tax_treatmentstates 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 itself —
pos.sale_line/the Orders equivalent (neither built yet) are what snapshot a resolved price at transaction time.pricingsuppliesresolvePrice(); 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:
platform—platform.tenantis 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— notprice_change_log, which is append-only).inventory—price_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 againstinventory.item_variant.avg_cost_centsat resolution time (never cached — see DR-9).multi_loc—price_rule.site_id(nullable FK →multi_loc.site, ON DELETE RESTRICT) for site-scoped rules.crm—price_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.shared—price_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_idonprice_rule/price_list_assignment;changed_by_actor_idonprice_change_log) FKs toidentity.actor, neveridentity.identity_userdirectly (a v1 deviation this build corrects — v1 predates the agent-actor pattern). No new authority mechanism is introduced —pricingis a pure consumer ofidentity.agent_duty_grant(see §9).ai—decision_provenancejsonb onprice_rule/price_list_assignmentcitesmemory_refspointing atai.agent_memory.id(a markdown-proposing agent may read a learned seasonal-markdown pattern) anddelegated_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 pricing —
price_ruleexpresses 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_rulerow, instead of one row peritem_variant.item_scope_type(variant/category/brand/all) is an orthogonal WHAT-axis discriminator alongside the pre-existingscope_typeWHO-axis (customer/price_level/customer_group targeting): a rule can now target a specific variant (unchanged default behavior), an entireinventory.category, an entireinventory.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 assignment —
price_list_assignmentputs a customer or customer group onto aprice_level, itself date-windowed. - Markdown/dead-stock proposals — an agent can propose a markdown as an ordinary pending
price_rulerow; see the full loop in §8. - Margin-erosion detection —
price_type='cost_plus_percent'resolves live againstitem_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 trail —
price_change_logrecords 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:
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).charged_amount_minor_units(bigint, NOT NULL) — the actual amount charged to the customer AFTER anyPricingServicedisplay-rounding (Hard Contract 3); equal toresolved_amount_minor_unitswhen no rounding was applied.currency_code(char(3)).tax_treatment(text).resolving_price_rule_id(nullable FK) — for traceability; NULL when no rule matched and barebase_price_centswas used.resolved_quantity— the quantity the price was resolved against, needed to verify amin_qty-tier rule applied correctly (a rule can cover multiple tiers via separate rows, soresolving_price_rule_idalone 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_attiebreak. cost_plus_percent's liveavg_cost_centsjoin.applies_all_sitesresolution.- At least one
min_qtytier 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
- Inventory maintains
stock.last_movement_at(already built) — the primary, already-documented dead-stock signal. - A pricing agent (
agent_duty_grantgrants itpricing:price_rule:propose_markdownatauthority_level='draft_only',scope_type='site'or'tenant') scans for variants with stalelast_movement_at. - Agent INSERTs a
price_rulerow 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":[...]}. - Agent logs one
ai.agent_executionrow: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 thetarget_row_id IS NULLcreate-new-record caseagent_execution.resolves_execution_idexists for.authority_level_applied='draft_only'. - A human reviews the pending
price_rulerow and setsreview_status='approved'+reviewed_by_actor_id/reviewed_at— this is the human action, captured onprice_ruleitself, not a secondagent_executionrow.ai.agent_execution.agent_identity_idis NOT NULL — every row in that table must attribute to a real agent, structurally, so a bare human click cannot be logged as a secondagent_executionrow at all, regardless of design preference.resolves_execution_idstays 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 secondagent_executionrow for its own human-review step either — this is established, codebase-wide precedent, not a pricing-specific exception. PricingService.resolvePrice()now includes this rule (oncePricingServiceexists); a service hook writes aprice_change_logrow (change_type='rule_created',source_type='agent').
A pending markdown proposal needs no separate propose/execute staging table (unlike stock_adjustment_request→stock_movement or customer_merge_candidate→customer_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 likecrm's row was. But it was still a pre-v2 count: no currency FK (rawchar(3)default'USD', sinceshared.currencydidn't exist at v1 design time), no agent/AI concept anywhere (price_change_log.source_typeCHECK had no'agent'value;changed_byFKsidentity.identity_userdirectly, 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_idalways 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; oneprice_ruletable absorbs all pricing kinds via discriminator columns, not per-kind tables; precedence resolution isPricingServicelogic, never a DB constraint; base price source of truth staysitem_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 NULLFK →platform.tenant, RLS enabled with a permissive tenant-isolation policy named<table>_tenant_isolation, matching the naming convention live-confirmed onitem_variant/site/customer/agent_duty_grant, scoped againstcurrent_setting('app.current_tenant_id')::uuid, plus a plain index ontenant_id. No mixed-scope (nullabletenant_id) case exists anywhere in this module, unlikeai.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 toshared.currency.decimal_placesisn't expressible in Postgres without a trigger (the same limitation already documented forai_request.tenant_id/agent_identity_idanddecision_provenance.memory_refs). The actionable half of this gap IS same-row-checkable:price_valueforfixed_price/amount_off/cost_plus_percent-adjacent currency amounts must always be a whole-number integer count of the currency's smallest unit — matchingitem_variant.base_price_cents's own established "cents" convention. This works uniformly across currencies without needingdecimal_placesat the CHECK level (a JPYprice_valueof1500means ¥1500 since JPY's smallest unit IS the yen; a USDprice_valueof1500means $15.00) — the difference is purely in DISPLAY (divide by10^shared.currency.decimal_places), not in whether the stored value must be whole.percent_off/cost_plus_percentare 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_valueis a barenumeric, not this project's standard_cents bigintconvention — a deliberate deviation, since one column must hold both currency amounts (integer smallest-unit counts) AND percentages (fractional). This mirrorsshared'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). Everyprice_rulerow now states explicitly whether itsprice_valueis 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(INinclusive/exclusive). Known, explicitly-flagged residual gap:inventory.item_variant.base_price_cents(the fallback when noprice_rulematches) has NOtax_treatmentconcept 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 revisitsitem_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_codeare now never mutated in place. A price change = a brand-new row + the old row'sstatusflipped tosupersededwithsuperseded_by_idpointing at the new row.is_activeis removed entirely, replaced bystatus(CHECKactive/superseded/expired). Two ORTHOGONAL state machines now coexist onprice_rule:review_statusgoverns whether a human has approved a row for resolution;statusgoverns whether THIS row is the current version of a price or has been replaced/aged out. A newly-created draft startsstatus='active'+review_status='pending'— independent dimensions, not a combined lifecycle. The write path for a genuine price change: INSERT a new row (freshid, new values,status='active'), then UPDATE the OLD row'sstatus='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 itsvalid_until); resolution correctness never depends on the sweep having run, sinceresolvePrice()always filters onvalid_from/valid_untildirectly regardless ofstatus. Reversing a bad change (D10): either soft-delete viadeleted_at(pulled, no replacement) or supersede with a corrected row (the normal mechanism) —is_activetoggling 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(mirrorsai.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. Withis_activegone 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_reactivatedno longer corresponds to any real transition (there is no "un-supersede"/"un-expire" — bringing a price back is naturally a freshrule_createdrow, or arule_supersededevent if it explicitly replaces an expired/deleted one), andrule_deactivatedis redundant withrule_deletednow thatis_activeis gone (both describe "pulled, no replacement" — collapsed into one). A new value,rule_superseded, covers "this row was replaced by a successor" (previously conflated withrule_updated, which now means only non-price-field edits —reason,priority,campaign_label, or areview_statustransition). 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_created—old_value=NULL,new_value={"price_type":"percent_off","price_value":15,"status":"active","currency_code":"USD"};rule_superseded—old_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_changed—old_value={"base_price_cents":2999},new_value={"base_price_cents":2499}(aboutitem_variant.base_price_cents, not aprice_rulefield —price_rule_idis NULL for thischange_type).DR-7 — two supersession-cycle bugs found by adversarial verification, both fixed. (1) Nothing prevented
superseded_by_idfrom being set to the row's OWNid(a trivial self-reference, passing the NULL-ness CHECK alone). Fixed withchk_price_rule_no_self_supersede:superseded_by_id IS NULL OR superseded_by_id <> id. (2) A longer cycle (row A'ssuperseded_by_id=B, row B'ssuperseded_by_id=A, bothstatus='superseded') satisfies every other CHECK/index independently (theUNIQUE (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 hassuperseded_by_id IS NULLby 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 buildsPricingService's write path MUST always (a) INSERT the new row first with nosuperseded_by_idreference to it from anywhere, then (b) UPDATE only the ONE specific predecessor row being replaced — never manually re-point an already-superseded row'ssuperseded_by_id, never chain more than one hop per write.The most severe finding this round —
superseded_by_idcross-tenant/variant/scope leak. A bare self-FK with no composite constraint tying the target'stenant_id/item_variant_id/scope_typeto 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_idis 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 fromitem_variant.currency_codeis "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 aprice_ruleexists, the rule'sprice_valuewould be silently reinterpreted under the new currency with zero audit trail — worse than v1's static column in this one scenario. Fix:currency_codeis a real NOT NULL FK →shared.currency.iso_code, populated fromitem_variant.currency_codeat INSERT time and never updated thereafter — a point-in-time snapshot, not a live derivation, mirroringagent_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 4thprice_type:price_valueis interpreted as a markup percentage overitem_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 reusesprice_rulein reverse" capability — without it, that claim had no real resolution path. Critical behavioral requirement: unlikefixed_price/percent_off/amount_off(resolved against a relatively stable anchor),cost_plus_percentMUST be resolved by joiningitem_variant.avg_cost_centsLIVE at resolution time, never cached/snapshotted — the entire point of this price_type is to track cost fluctuations dynamically. Documented as a bindingPricingServicerequirement (Hard Contract 2, §7).The bound-fix. The original branch only required
price_value>0(reusingpercent_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-100express a markdown below cost (e.g.-20= 20% belowavg_cost_cents, floored at-100= free/$0, never negative-priced),0expresses selling exactly at cost, and the1000ceiling (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 bychk_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: acustomer/customer_group-scoped rule withsite_id=NULLsilently 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: forscope_type IN ('customer','customer_group'), the author (human or agent) must make an EXPLICIT choice — either pinsite_idto a specific site, or explicitly setapplies_all_sites=true.default/price_level-scoped rules are unaffected (their ownsite_id=NULLalready 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=trueANDsite_idboth set on acustomer-scoped row is rejected — see the next fix.Round-2-verification fix —
applies_all_sites+site_idset simultaneously was silently ambiguous. Nothing prevented BOTH being set at once, and the documented sync query only creditedapplies_all_siteswhensite_id IS NULL— a row with both set would silently behave as site-only, withapplies_all_sites=truebecoming a dead, misleading flag exactly when a form or agent might default both fields together. Fixed withchk_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 noreview_statusfilter — a PENDING, not-yet-approved, agent-authored row withapplies_all_sites=truewould replicate to every POS device tenant-wide via sync, independent of whetherresolvePrice()itself ever prices it. This reopens exactly the riskapplies_all_sitesexists to close, at the sync/replication layer even though the pricing layer stays correctly gated byreview_status. Fixed by making the gate explicit in the documented sync contract: Flutter's sync query isWHERE (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=trueon acustomer/customer_group-scoped row,decision_provenanceMUST 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 bundledapplies_all_sites=trueflag (defaulting tofalse, 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 groupprice_rule/price_change_logrows by a human-assigned label (e.g."spring_sale_2026") instead of fuzzy-matching free-textreasonstrings, 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 ascope_type='price_level'row ALSO setcustomer_id— a leaked second FK, live-confirmed as a real bug (INSERT 0 1succeeded 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 setscustomer_group_id; correctly rejectsdefault-scoped rows with any FK set; correctly requires the matching FK for its own scope. Does NOT forbidcustomer_id+site_idset together — adversarial verification confirmedsite_idsits 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 withreview_status='not_required'leavereviewed_by_actor_idNULL 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 naiveUNIQUE (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 viaCOALESCE(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_qtywas missing. Adversarial verification found the index omittedmin_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 addingCOALESCE(min_qty, 0)to the key — same-quantity duplicates still correctly collide; different quantity tiers no longer falsely collide.Fix 2 —
price_typewas missing. Adversarial verification found the key also omittedprice_type, so acost_plus_percentmarkup proposal and an unrelatedfixed_priceproposal 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. Addingprice_typeto 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 gatedAND status = 'active'— a superseded row, which shouldn't normally carryreview_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 pendingfixed_priceproposal for the exact same scope as an existing pendingfixed_priceis correctly rejected as a genuine duplicate.DR-15 — the
idempotency_keygrain, corrected to include the actor dimension. Adversarial verification found the original(tenant_id, idempotency_key)grain omits the actor dimensionai.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_idis NOT NULL throughout pricing, no two-partial split needed unlikeai_request's platform-level nullable-tenant case). Applied identically toprice_list_assignment's ownidempotency_key.DR-16 — the hot-path resolution index, updated for the
statusstate machine. SupportsresolvePrice()'s actual query shape — the module's single hottest read path (every POS/Orders line-item price resolution) — replacing the removedis_activecolumn: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') andprice_rule.name(nullable text) restored, along withprice_rule_sale_expiry_idx(partial index onvalid_untilWHERE 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_kindclassified a rule's TYPE independent of its date window and backed a sale-expiry sweep job in v1 —campaign_labelis a free-text promo tag, not a type classification, and cannot substitute for either capability.namewas v1's human display label (e.g. "Wholesale tray price") —campaign_label/reasonare 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 invalidrule_kindvalues by exact constraint name; the index exists with the exact claimed WHERE clause;nameaccepts both NULL and a real string). See PROJECT_DECISIONS #28.DR-18 (added 2026-07-07, pricing reopen) —
price_list_assignment.is_active(boolean, defaulttrue) restored along with two partial-unique indexes guaranteeing at most one open-ended (valid_until IS NULL), active, non-deleted assignment percustomer_idand percustomer_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 anis_active=falserow does not conflict; soft-deleting the original correctly frees the uniqueness slot without opening a gap for a third live conflicting row; the pre-existingchk_price_list_assignment_customer_xor_groupCHECK 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_reviewerandprice_list_assignment.chk_price_list_assignment_approved_requires_revieweradded, same shape as the equivalent billing/inventory instances added in the same cross-cutting pass. Both CHECKs enforcereview_status != 'approved' OR reviewed_by_actor_id IS NOT NULL— closing a gap where a row could be markedapprovedwith 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 fromgen_random_uuid()toplatform.uuid_generate_v7().price_change_logis 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_typevs.scope_type, and why item-scope resolution precedence is deferred toPricingServicerather than schema-enforced. The gap-fill task's own originally-proposed column name for category/brand-scoped rules wasscope_type— butprice_rule.scope_typealready exists, governing an entirely different axis: WHO a rule targets (default/price_level/customer/customer_group, enforced bychk_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 singleprice_rulerow already varies independently along both axes (e.g. acustomer_group-scoped rule that also happens to becategory-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 forreview_statusvs.statuson this same table — a recurring pattern onprice_rulespecifically: 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... isPricingServiceapplication 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: noPricingServiceexists 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 acategory-scoped row is invalid merely because avariant-scoped row might also apply to the same line at resolution time. The precedence order itself (variant > category > brand > all — most-specific-wins, matching howscope_type's own specificity ordering already works) is recorded as a documented requirement onitem_scope_type(see its column comment in the schema) and folds into Hard Contract 2's existing "ONE versionedresolvePrice()spec + golden test vectors" obligation (§7) — whoever buildsPricingServicemust 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 tocrm.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(neveridentity.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_statusCHECK-enumerated as usual; the separatestatus(active/superseded/expired) state machine is CHECK-enumerated and, unlike a booleanis_activetoggle, is supersede-don't-edit rather than a reversible in-place flip (DR-5). Decision provenance:decision_provenancejsonb, citingstock.last_movement_atas evidence for markdown proposals, extended withmemory_refs/delegated_by_actor_idkeys per the now-realai.agent_memorytarget. Agent/tenant memory: a markdown-proposing agent MAY readai.agent_memory(e.g. a learned seasonal-markdown pattern) — recorded viadecision_provenance.memory_refs, no new column.price_list_assignment— full autonomy treatment too (unlike the staticprice_levelcatalog): assigning a customer to a price tier is a plausible agent capability, directly analogous tocrm.customer_segment_membership's own "agent-computed... wholesale-like classification" precedent, so it gets the identical column set asprice_rule.price_change_log— append-only ledger, matchesai.agent_execution's own append-only shape (noupdated_at/deleted_at). Getsautomation_sourceonly (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 onprice_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_id→inventory.item_variant.id(enforced, ON DELETE RESTRICT).cost_plus_percent-typed rules additionally resolve live againstitem_variant.avg_cost_centsat resolution time (DR-9) — a read-time join, not an FK. - pricing → multi_loc:
price_rule.site_id→multi_loc.site.id(enforced, nullable, ON DELETE RESTRICT). - pricing → crm:
price_rule.customer_id/customer_group_id,price_list_assignment.customer_id/customer_group_id→crm.customer.id/crm.customer_group.id(enforced, nullable). Customer-specific and group price-list assignments resolved via CRM identity. - pricing → shared:
price_rule.currency_code→shared.currency.iso_code(enforced) — snapshotted at INSERT time, never updated thereafter (DR-8). - pricing → identity: every
*_by_actor_id/changed_by_actor_idcolumn →identity.actor.id(enforced). No new authority mechanism — pure consumer ofidentity.agent_duty_grant(§9). - pricing → ai:
decision_provenance.memory_refs(documented onprice_rule/price_list_assignment) resolves toai.agent_memory.id— a plain-UUID reference inside a JSONB array, not an enforced FK, the same patterndecision_provenanceuses 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 buildspos.sale_line/Orders' equivalent andPricingServiceitself. This strengthensCROSS_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.