offers — new module (2026-07-11), merchant-issued discount/promo offers — THE AI module of this build
7 tables, 127 columns (up from 123; originally 6/115 at the 2026-07-11 build) — schema built and migration applied/verified live 2026-07-11; locked the same day (PROJECT_DECISIONS #57), then reopened again later the same day (still 2026-07-11) to fix a real, independent, live bug — offers.check_and_sync_offer_budget() had no magnitude check at all on a 'reverse' redemption row, only the pre-existing sign CHECK; a $10 redemption could be "reversed" by a fabricated $1,000 row and the trigger's own budget-cap floor check would not catch it — and to add proportional/cumulative-cap partial reversal as a first-class capability, via a new mutable tracker table, offer_redemption_reversal_tracker (PROJECT_DECISIONS #60); re-locked same day, still schema-only. This build also independently discovered (not caused, and disclosed rather than silently folded into the arithmetic) a pre-existing, this-fix-unrelated column-count drift: offers.offer is live-confirmed at 43 columns (pre-2026-07-20), not the 42 the original 2026-07-11 build's own MODULE_INDEX row recorded — the extra column, redemption_count, was genuinely added same-day by that build's own lock-gate verification fix pass (PROJECT_DECISIONS #57) but never reflected in the recorded column count at the time; see its own table section below. Reopened again 2026-07-20 (PROJECT_DECISIONS #74, Gap-Fill Batch item B6) to add a new, additive buy_x_get_y discount_type — 4 new nullable bxgy_* columns on offer (43→47 cols) plus a new chk_offer_bxgy_coherence CHECK, and a real bug fix: the PRE-EXISTING chk_offer_discount_type_coherence CHECK had no branch at all for the new value, so every buy_x_get_y insert would have unconditionally failed it — closed the same day via a follow-up migration widening that older CHECK with a 4th branch (see the offer table's own CHECK section below). offers is now 7 tables / 127 cols (up from 123). offers is Vrida's merchant-issued (and optionally AI-suggested) discount/promo engine: offer definitions, coupon codes, per-consumer assignment lifecycle, the redemption ledger, AI targeting rules, a discount-exposure rollup, and (new) a per-redemption reversal tracker. Tenant-scoped throughout, the same structural inversion as rewards. This is the AI module of the 3-schema build: an offer-generating agent is an ordinary identity.agent_identity with an ordinary identity.agent_duty_grant (permission offers:offer:propose) — zero new AI infrastructure was built. What IS new and structural: margin/discount guardrails are DB-enforced by trigger, not service-layer-hoped — see "The atomic guardrail+sync trigger" below.
Depends on platform (tenant), multi_loc (site), identity (actor), consumer (consumer.consumer — bare FK), pos (sale, sale_line — composite FKs; sale needs the same UNIQUE (id, tenant_id) prerequisite rewards needed, added once by this same migration and shared by both modules; sale_line already carries UNIQUE (id, tenant_id) since the pos reopen, PROJECT_DECISIONS #46 — no new prerequisite needed here), crm (customer_segment_definition — a plain, non-composite FK, since crm.customer_segment_definition itself lacks UNIQUE (id, tenant_id); cross-tenant integrity is enforced instead by a dedicated trigger — see below), and inventory (read-only, not FK'd: the margin-guardrail trigger reads inventory.item_variant.avg_cost_cents directly via a pos.sale_line join; offer.free_item_variant_ref/reward_option-style loose refs are text, not FK'd). No longer depends on shared — offer_targeting_rule.growing_zone_code's FK into shared.climate_zone was dropped 2026-07-18 (Phase 2, PROJECT_DECISIONS #70); the renamed attribute_ref column is now a loose, unFK'd reference — see that table's own section below.
This build also widened approvals.approval_request.source_module's CHECK to add 'offers' as a valid value (confirmed live value set was purchasing/pos/orders/identity/crm/inventory/ai/other; a CHECK widen never invalidates existing rows) — a cross-module side effect landing in this same migration, not a schema change to offers itself.
PROJECT_DECISIONS entry: #57 (original 2026-07-11 build + lock), #60 (same-day reopen — closes an independent live magnitude-check bug on reversal, adds proportional/partial reversal via offer_redemption_reversal_tracker), #70 (2026-07-18 Phase 2 reopen — offer_targeting_rule.growing_zone_code renamed attribute_ref, FK to shared.climate_zone dropped), and #74 (2026-07-20 Gap-Fill Batch, item B6 — new buy_x_get_y discount_type + a real chk_offer_discount_type_coherence coverage bug found and fixed the same day).
Global rules for this schema:
- Uniform tenant-scoping — all 7 tables carry
tenant_id NOT NULLFK →platform.tenant, standard RLS<table>_tenant_isolationpolicy,FOR ALL TO authenticated. updated_attrigger-maintained viaplatform.set_updated_at()on 6 of 7 tables (offer,offer_code,offer_assignment,offer_targeting_rule,customer_discount_exposure, and — added by the same-day reopen —offer_redemption_reversal_tracker). Not onoffer_redemption— an append-only ledger fact with noupdated_atcolumn.- Soft delete (
deleted_at) on the same original 5 tables. Not onoffer_redemption(append-only ledger fact) nor on the newoffer_redemption_reversal_tracker(a small mutable counter row with no lifecycle to soft-delete). - Append-only, 1 table:
offer_redemption—REVOKE UPDATE, DELETE FROM authenticated+ triggertrg_offer_redemption_append_only, reusingplatform.reject_append_only_mutation()verbatim. PK defaultplatform.uuid_generate_v7(). The newoffer_redemption_reversal_trackeris deliberately the opposite — genuinely mutable — precisely because the redemption ledger itself cannot host a maintained running-total column (see below). - Agent-as-actor — every
*_actor_idtargetsidentity.actor. - Autonomy tiers, non-uniform by design.
offergets the FULL pack:created_by_actor_id+automation_source+ the full review seam (review_status/review_reason/reviewed_by_actor_id/reviewed_at) +decision_provenance, plusoffers-specific AI fields (provenance,status, the 2 guardrail-ceiling columns,min_margin_percent).offer_targeting_rulegets a 6-column pack (the 5-column review seam +created_by_actor_id, but nodecision_provenance).offer_code,offer_assignment,offer_redemption,customer_discount_exposure, and the newoffer_redemption_reversal_trackerget no autonomy columns at all — transactional instrument/lifecycle/ledger-fact/maintained-counter shapes, each deterministic given its inputs. - THE STRUCTURAL AI GUARDRAIL.
chk_offer_ai_requires_guardrail(provenance = 'human_defined' OR max_discount_percent IS NOT NULL OR max_discount_amount_per_order IS NOT NULL) — any AI-touched offer MUST carry at least one discount ceiling; NULL-safe by construction sinceprovenanceisNOT NULL. Enforced together withoffers.check_and_sync_offer_budget()(see below), which merges the margin-floor check, the guardrail-ceiling check, and the budget-cap sync into one atomicBEFORE INSERTstatement onoffer_redemption— closing both a concurrency race and an AI margin-bypass gap the design's own verification found. Reopened the same day (PROJECT_DECISIONS #60) to close a real, independently-discovered, live bug: the function had no magnitude check at all on a'reverse'row, only the pre-existing sign CHECK — live-reproduced by temporarily reinstalling the exact pre-fix body, a $10 (1000-cent) redemption padded by an unrelated legitimate $990 redemption on the same offer was "reversed" by a fabricated -$1000 row and succeeded, zeroingbudget_used_centsby silently consuming the other redemption's budget. Now fixed via the same cumulative-cap tracker mechanism asrewards(a newoffer_redemption_reversal_trackertable) — see "The atomic guardrail+sync trigger" below for the full corrected mechanism. offer_targeting_rule's cross-tenant-integrity trigger,trg_offer_targeting_rule_validate_segment— mirrorspricing.trg_price_rule_validate_supersession's own precedent: sincesegment_definition_idis a plain (non-composite) FK, a dedicated trigger enforces same-tenant-or-global at the DB level instead. See "The segment cross-tenant guard" below.
Cross-Phase / Cross-Module Foreign Keys (offers)
| Column | Target | Notes |
|---|---|---|
*.tenant_id (all 7 tables) |
platform.tenant |
NOT NULL |
offer.site_id |
multi_loc.site (id, tenant_id) |
nullable, composite FK offer_site_tenant_fkey |
offer.consumer_id, offer_code.consumer_id, offer_assignment.consumer_id, offer_redemption.consumer_id, customer_discount_exposure.consumer_id |
consumer.consumer.id |
bare — non-tenant-scoped target, no composite possible. NOT NULL on offer_assignment/offer_redemption/customer_discount_exposure; nullable on offer (targeted-only) and offer_code |
offer_code.offer_id, offer_assignment.offer_id, offer_redemption.offer_id, offer_targeting_rule.offer_id |
offers.offer (id, tenant_id) |
NOT NULL, composite |
offer_assignment.offer_code_id, offer_redemption.offer_code_id |
offers.offer_code (id, tenant_id) |
nullable, composite |
offer_redemption.offer_assignment_id |
offers.offer_assignment (id, tenant_id) |
nullable, composite |
offer_redemption.sale_id |
pos.sale (id, tenant_id) |
NOT NULL, composite. Shares the same sale_id_tenant_id_unique prerequisite rewards.loyalty_point_ledger.sale_id needed — added once by this migration |
offer_redemption.sale_line_id |
pos.sale_line (id, tenant_id) |
nullable, composite. The margin-floor lookup key (→ inventory.item_variant.avg_cost_cents via join). No new prerequisite needed — sale_line already carries UNIQUE (id, tenant_id) since the pos reopen (PROJECT_DECISIONS #46) |
offer_redemption.reversed_redemption_id |
offers.offer_redemption (id, tenant_id) |
nullable, self-referencing composite |
offer_redemption_reversal_tracker.redemption_id |
offers.offer_redemption (id, tenant_id) |
NOT NULL, composite. NEW (same-day reopen, PROJECT_DECISIONS #60). UNIQUE (tenant_id, redemption_id) — one tracker row per original redemption, also the ON CONFLICT target for the lazy-create insert |
offer_targeting_rule.segment_definition_id |
crm.customer_segment_definition.id |
nullable, plain (non-composite) FK — crm.customer_segment_definition lacks UNIQUE (id, tenant_id) entirely (live-confirmed). Cross-tenant integrity enforced instead by trg_offer_targeting_rule_validate_segment |
offer.reviewed_by_actor_id, .created_by_actor_id, offer_targeting_rule.created_by_actor_id, .reviewed_by_actor_id |
identity.actor |
nullable, bare |
offer.bxgy_reward_variant_id |
inventory.item_variant (id, tenant_id) |
nullable, composite FK. NEW (2026-07-20, PROJECT_DECISIONS #74, gap B6). NULL means the reward is the SAME item being qualified on |
offers.offer (47 cols, FULL autonomy) — the offer definition
v1's full 27 columns preserved (explicitly walked and verified against the build's own Finding 5 — an earlier draft's "27 preserved" claim was disproven for 11 real v1 columns; every one is confirmed present here: id, tenant_id, created_at, updated_at, deleted_at, name, description, funding_source, discount_type, discount_percent, discount_amount_cents, max_discount_cents, free_item_variant_ref, min_purchase_cents, distribution_type, consumer_id, eligibility_json, stacking_policy, exclusive_group, priority, max_redemptions, max_per_consumer, budget_cents, budget_used_cents, valid_from, valid_to, is_active) + 16 new: site_id, applies_all_sites, provenance, automation_source, review_status, review_reason, reviewed_by_actor_id, reviewed_at, decision_provenance, status, max_discount_percent, max_discount_amount_per_order, excluded_categories, min_margin_percent, created_by_actor_id, redemption_count. 27 + 16 = 43. + 4 more (2026-07-20, PROJECT_DECISIONS #74, Gap-Fill Batch item B6): bxgy_qualifying_qty, bxgy_reward_qty, bxgy_reward_variant_id, bxgy_reward_discount_pct — supporting the new buy_x_get_y discount_type. 43 + 4 = 47.
Disclosed count correction (not caused by, but discovered during, the 2026-07-11 same-day reopen, PROJECT_DECISIONS #60): redemption_count was genuinely added same-day by the original build's own lock-gate verification fix pass (packages/db/migrations/20260711010000_consumer_rewards_offers_verify_fixes.sql, BLOCKER 3 fix — redemption_count integer NOT NULL DEFAULT 0 + chk_offer_redemption_count_nonneg + chk_offer_redemption_count_le_cap), but this doc's own MODULE_INDEX-cited total continued to say 42 cols until the proportional-reversal reopen re-counted from the live CREATE TABLE/ALTER TABLE history and caught the drift. Disclosed here rather than silently absorbed into the arithmetic, matching this codebase's established convention for this exact bug class (e.g. shared's 12-column Phase 4 drift).
status is a separate lifecycle from is_active (mirrors pricing.price_rule's own two-state-machine precedent) — is_active stays v1's simple boolean quick-disable toggle; status adds draft/suspended/expired distinctions the boolean can't express. max_discount_percent/max_discount_amount_per_order are guardrail ceilings, distinct from discount_percent (the offer's actual value when discount_type='percent_off') and max_discount_cents (v1's percent-off-only cap) — the guardrails apply regardless of discount_type and are what chk_offer_ai_requires_guardrail enforces for AI-touched offers.
Tenant-scoped. RLS enabled —
offer_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at().
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
name |
text | NOT NULL | — | |
description |
text | nullable | — | |
funding_source |
text | NOT NULL | 'business' |
CHECK IN (business,vrida) |
discount_type |
text | NOT NULL | — | CHECK IN (percent_off,amount_off,free_item,bogo,buy_x_get_y) — buy_x_get_y added 2026-07-20 (PROJECT_DECISIONS #74, gap B6); bogo (classic 1-for-1 free_item_variant_ref swap) is unchanged and unaffected |
discount_percent |
numeric | nullable | — | Required IFF discount_type='percent_off'. CHECK range (0, 100] |
discount_amount_cents |
bigint | nullable | — | Required IFF discount_type='amount_off' |
max_discount_cents |
bigint | nullable | — | Percent-off-only cap (v1) — CHECK requires discount_type='percent_off' when set |
free_item_variant_ref |
text | nullable | — | Loose ref — inventory.item_variant, validated at redemption time. Required IFF discount_type IN ('free_item','bogo') |
min_purchase_cents |
bigint | nullable | — | |
distribution_type |
text | NOT NULL | — | CHECK IN (broadcast,targeted,code) |
consumer_id |
UUID | nullable | — | FK → consumer.consumer (bare). Only settable when distribution_type='targeted' |
eligibility_json |
jsonb | nullable | — | |
stacking_policy |
text | NOT NULL | 'exclusive' |
CHECK IN (exclusive,stackable,best_price) |
exclusive_group |
text | nullable | — | |
priority |
integer | NOT NULL | 0 |
|
max_redemptions |
integer | nullable | — | |
max_per_consumer |
integer | nullable | — | |
budget_cents |
bigint | nullable | — | |
budget_used_cents |
bigint | NOT NULL | 0 |
Trigger-maintained via offers.check_and_sync_offer_budget() |
valid_from |
timestamptz | nullable | — | |
valid_to |
timestamptz | nullable | — | |
is_active |
boolean | NOT NULL | true |
Simple boolean quick-disable — separate from status |
site_id |
UUID | nullable | — | Composite FK → multi_loc.site. XOR with applies_all_sites |
applies_all_sites |
boolean | NOT NULL | false |
|
provenance |
text | NOT NULL | 'human_defined' |
CHECK IN (human_defined,ai_suggested,ai_auto_created) |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — | |
decision_provenance |
jsonb | nullable | — | Project-wide reason/evidence/confidence/memory_refs convention |
status |
text | NOT NULL | 'draft' |
CHECK IN (draft,active,suspended,expired) — separate lifecycle from is_active |
max_discount_percent |
numeric | nullable | — | Guardrail ceiling. CHECK range (0, 100]. Required for any non-human_defined offer unless max_discount_amount_per_order is set |
max_discount_amount_per_order |
bigint | nullable | — | Guardrail ceiling. Required for any non-human_defined offer unless max_discount_percent is set |
excluded_categories |
jsonb | nullable | — | |
min_margin_percent |
numeric | nullable | — | CHECK range [0, 100]. The margin floor offers.check_and_sync_offer_budget() enforces at redemption time |
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
redemption_count |
integer | NOT NULL | 0 |
Trigger-maintained via offers.check_and_sync_offer_budget() — net redemptions (redeem +1, fully-reversed reverse -1; a partial reversal does not move this). Added same-day by the original build's own lock-gate fix pass, disclosed above as a pre-existing doc-count drift |
bxgy_qualifying_qty |
integer | nullable | — | NEW (2026-07-20, PROJECT_DECISIONS #74, gap B6). CHECK > 0. Required IFF discount_type='buy_x_get_y' — quantity of the qualifying item that must be purchased |
bxgy_reward_qty |
integer | nullable | — | NEW. CHECK > 0. Required IFF discount_type='buy_x_get_y' — quantity of the reward item granted |
bxgy_reward_variant_id |
UUID | nullable | — | NEW. Composite FK → inventory.item_variant (id, tenant_id). NULL means the reward is the SAME item being qualified on (e.g. buy 3 of X, get 1 of X free/discounted); non-NULL names a different reward item |
bxgy_reward_discount_pct |
numeric | nullable | — | NEW. CHECK > 0 AND <= 100 (100 = fully free). Required IFF discount_type='buy_x_get_y' — the discount applied to each reward-qty unit |
CHECK constraints (21, up from 20 — chk_offer_bxgy_coherence added 2026-07-20, PROJECT_DECISIONS #74, gap B6; the pre-existing 20 were themselves up from 18 in the original design walk, 2 added same-day by the original build's own lock-gate fix pass alongside redemption_count itself, see the disclosed count correction above): chk_offer_funding_source; chk_offer_discount_type; chk_offer_discount_type_coherence (now 4-branch, widened 2026-07-20 — one column set required per discount_type, others forbidden; the original 3 branches covered percent_off/amount_off/free_item+bogo, and a same-day follow-up migration, 20260720000008_offers_fix_bxgy_discount_type_coherence.sql, added the missing 4th branch requiring free_item_variant_ref/discount_percent/discount_amount_cents all NULL when discount_type='buy_x_get_y' — a real bug: the original B6 migration widened chk_offer_discount_type and added chk_offer_bxgy_coherence but missed that this OLDER, separate CHECK had no branch at all for the new value, so every buy_x_get_y insert would have unconditionally failed it regardless of the new bxgy_* columns being correct; found during this build's own live-reproduction testing, not the original design); chk_offer_discount_percent_range; chk_offer_max_discount_cents_percent_only; chk_offer_distribution_type; chk_offer_targeted_consumer (distribution_type = 'targeted' OR consumer_id IS NULL); chk_offer_stacking_policy; chk_offer_budget_used_nonneg; chk_offer_budget_used_le_cap (budget_used_cents <= budget_cents OR budget_cents IS NULL); chk_offer_applies_all_sites_xor_site_id; chk_offer_customer_scope_site_explicit (distribution_type != 'targeted' OR site_id IS NOT NULL OR applies_all_sites = true); chk_offer_provenance; chk_offer_ai_requires_guardrail (the structural AI guardrail — see above); chk_offer_automation_source; chk_offer_review_status; chk_offer_status; chk_offer_max_discount_percent_range; chk_offer_min_margin_percent_range; chk_offer_redemption_count_nonneg; chk_offer_redemption_count_le_cap (max_redemptions IS NULL OR redemption_count <= max_redemptions); chk_offer_bxgy_coherence (NEW, 2026-07-20 — discount_type = 'buy_x_get_y' requires bxgy_qualifying_qty/bxgy_reward_qty/bxgy_reward_discount_pct all NOT NULL; any OTHER discount_type requires all 4 bxgy_* columns NULL).
Indexes (5): PK on id; offer_id_tenant_id_unique (UNIQUE — prerequisite for every child table's composite FK); offer_tenant_id_idx (partial, WHERE deleted_at IS NULL); offer_tenant_id_is_active_idx (partial); offer_consumer_id_idx (partial); offer_distribution_type_idx (partial).
offers.offer_code (13 cols)
v1 unchanged. Coupon codes for distribution_type='code' offers. Plain text (typed/shared, not bearer secrets) — normalized_code for case-insensitive lookup. redeemed_count is trigger-maintained by offers.check_and_sync_offer_budget() — same treatment as offer.redemption_count: it only decrements on a reversal that fully exhausts the original redemption's discount amount (a merely-partial reversal releases budget proportionally without touching this count), per the same-day proportional-reversal reopen (PROJECT_DECISIONS #60).
Tenant-scoped. RLS enabled —
offer_code_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). No autonomy pack.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
offer_id |
UUID | NOT NULL | — | Composite FK → offers.offer |
normalized_code |
text | NOT NULL | — | Case-insensitive lookup key |
code_display |
text | nullable | — | |
consumer_id |
UUID | nullable | — | FK → consumer.consumer (bare) |
max_redemptions |
integer | nullable | — | |
redeemed_count |
integer | NOT NULL | 0 |
Trigger-maintained via offers.check_and_sync_offer_budget() |
expires_at |
timestamptz | nullable | — | |
status |
text | NOT NULL | 'active' |
CHECK IN (active,exhausted,expired,revoked) |
CHECK constraints (2): chk_offer_code_redeemed_count_nonneg; chk_offer_code_status.
Indexes (4): PK on id; offer_code_id_tenant_id_unique (UNIQUE); offer_code_tenant_id_normalized_code_unique (UNIQUE, WHERE deleted_at IS NULL); offer_code_offer_id_idx; offer_code_consumer_id_idx (partial).
offers.offer_assignment (15 cols)
v1 unchanged. Per-consumer lifecycle of a targeted offer: issued → viewed → claimed → redeemed (or expired/cancelled).
Tenant-scoped. RLS enabled —
offer_assignment_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). No autonomy pack.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
offer_id |
UUID | NOT NULL | — | Composite FK → offers.offer |
consumer_id |
UUID | NOT NULL | — | FK → consumer.consumer (bare) |
offer_code_id |
UUID | nullable | — | Composite FK → offers.offer_code |
status |
text | NOT NULL | 'issued' |
CHECK IN (issued,viewed,claimed,redeemed,expired,cancelled) |
assigned_at |
timestamptz | NOT NULL | now() |
|
viewed_at |
timestamptz | nullable | — | |
claimed_at |
timestamptz | nullable | — | |
expires_at |
timestamptz | nullable | — | |
source |
text | NOT NULL | — | CHECK IN (targeted,broadcast_optin,birthday,campaign,manual) |
eligibility_snapshot_json |
jsonb | nullable | — |
CHECK constraints (2): chk_offer_assignment_status; chk_offer_assignment_source.
Indexes (5): PK on id; offer_assignment_id_tenant_id_unique (UNIQUE); offer_assignment_tenant_offer_consumer_unique (UNIQUE, tenant_id+offer_id+consumer_id, WHERE deleted_at IS NULL); offer_assignment_consumer_id_idx; offer_assignment_offer_id_status_idx (partial); offer_assignment_status_active_idx (partial, WHERE status IN ('issued','claimed')).
offers.offer_redemption (15 cols) — append-only ledger fact
v1's 13 columns preserved verbatim + 2 new: sale_line_id (composite FK → pos.sale_line, no new prerequisite needed — see above) + channel. sale_id stays NOT NULL (v1's "THE REDEMPTION SEAM" — required on every redemption, order-level or line-level); sale_line_id is the OPTIONAL finer-grain pointer the margin-guardrail trigger needs to look up inventory.item_variant.avg_cost_cents for a line-level discount.
reversed_redemption_id deliberately carries no uniqueness constraint — the same-day reopen (PROJECT_DECISIONS #60) makes multiple, genuinely legitimate partial reverse rows against one original redeem row a first-class case (e.g. a UNIQUE (reversed_redemption_id) WHERE redemption_type='reverse' constraint, the pre-verification design's own instinct, would incorrectly block a second, legitimate partial reversal). The cumulative cap is enforced instead by the new offer_redemption_reversal_tracker table's own atomic UPDATE, not a uniqueness constraint on the FK column — see "The atomic guardrail+sync trigger" below.
Tenant-scoped, append-only. RLS enabled —
offer_redemption_tenant_isolation. REVOKE UPDATE, DELETE FROM authenticated + triggertrg_offer_redemption_append_only. Noupdated_at, no soft delete.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | platform.uuid_generate_v7() |
PK — append-only PK convention |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
offer_id |
UUID | NOT NULL | — | Composite FK → offers.offer |
consumer_id |
UUID | NOT NULL | — | FK → consumer.consumer (bare) |
offer_assignment_id |
UUID | nullable | — | Composite FK → offers.offer_assignment |
offer_code_id |
UUID | nullable | — | Composite FK → offers.offer_code |
sale_id |
UUID | NOT NULL | — | Composite FK → pos.sale — "THE REDEMPTION SEAM" (v1) |
sale_line_id |
UUID | nullable | — | NEW. Composite FK → pos.sale_line — the margin-floor lookup key |
redemption_type |
text | NOT NULL | 'redeem' |
CHECK IN (redeem,reverse) |
discount_amount_applied_cents |
bigint | NOT NULL | — | Sign enforced per redemption_type — positive for redeem, negative for reverse |
reversed_redemption_id |
UUID | nullable | — | Self-referencing composite FK. Required IFF redemption_type='reverse' |
redeemed_at |
timestamptz | NOT NULL | now() |
|
note |
text | nullable | — | Required IFF redemption_type='reverse' |
channel |
text | NOT NULL | 'pos' |
NEW. CHECK IN (pos,online,app) |
CHECK constraints (5): chk_offer_redemption_redemption_type; chk_offer_redemption_reverse_requires_original; chk_offer_redemption_amount_sign ((redemption_type = 'redeem' AND discount_amount_applied_cents > 0) OR (redemption_type = 'reverse' AND discount_amount_applied_cents < 0)); chk_offer_redemption_reverse_requires_note; chk_offer_redemption_channel.
Indexes (6): PK on id; offer_redemption_id_tenant_id_unique (UNIQUE — prerequisite for the self-referencing composite FK); offer_redemption_idempotency_unique (UNIQUE, tenant_id+offer_id+consumer_id+sale_id, WHERE redemption_type = 'redeem' — the dedup index for the redeem path); offer_redemption_offer_id_idx; offer_redemption_consumer_id_idx; offer_redemption_sale_id_idx; offer_redemption_offer_assignment_id_idx (partial); offer_redemption_reversed_redemption_id_idx (partial).
offers.offer_redemption_reversal_tracker (7 cols) — cumulative-reversed-amount tracker, added by the 2026-07-11 same-day reopen
New table (PROJECT_DECISIONS #60), not part of the original 2026-07-11 build. A small, genuinely mutable counter row per original offer_redemption row (redemption_type='redeem') that has ever been (partially or fully) reversed — lazily created on first touch (INSERT ... ON CONFLICT (tenant_id, redemption_id) DO NOTHING, snapshotting the original's own discount_amount_applied_cents; the lazy-create SELECT requires redemption_type='redeem' on the source row, so an attempt to reverse an already-reverse row finds no eligible original and the capped UPDATE below correctly matches zero rows), then atomically capped on every subsequent reversal by offers.check_and_sync_offer_budget()'s own UPDATE ... WHERE total_reversed_cents + abs(:amt) <= original_discount_amount_cents ... RETURNING. This closes BUG B — the independent, live magnitude-check gap this same reopen found (a $10 redemption could previously be "reversed" by a fabricated $1,000 row). Exists as a separate table, not a maintained column on offer_redemption itself, because that table is append-only via platform.reject_append_only_mutation() — a running-total column directly on it would hit that trigger on its first UPDATE. Mirrors offers.offer.budget_used_cents/.redemption_count's own established "maintained counter on a mutable row" precedent, and is the offers-side twin of rewards.loyalty_point_ledger_reversal_tracker (same shape, same atomic-cap mechanism, built in the same migration).
Tenant-scoped. RLS enabled —
offer_redemption_reversal_tracker_tenant_isolation.updated_at: trigger-maintained viaplatform.set_updated_at(). No soft delete — a small mutable counter row, not a lifecycle entity. No autonomy pack.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
redemption_id |
UUID | NOT NULL | — | Composite FK → offers.offer_redemption — the original redeem row being (partially) reversed |
original_discount_amount_cents |
bigint | NOT NULL | — | Snapshotted from the original redemption row's own discount_amount_applied_cents on first touch (lazy-create, ON CONFLICT (tenant_id, redemption_id) DO NOTHING) |
total_reversed_cents |
bigint | NOT NULL | 0 |
Cumulative running tally across every reverse row against this original, atomically capped in the same statement that increments it |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
CHECK constraints (1): chk_offer_redemption_reversal_tracker_total_reversed_bounds (total_reversed_cents >= 0 AND total_reversed_cents <= original_discount_amount_cents) — a defensive backstop; the real, atomic enforcement point is check_and_sync_offer_budget()'s own capped UPDATE (see "The atomic guardrail+sync trigger" below).
Indexes (2): PK on id; offer_redemption_reversal_tracker_tenant_redemption_unique (UNIQUE, tenant_id+redemption_id — one tracker row per original redemption, and the ON CONFLICT target for the lazy-create insert); offer_redemption_reversal_tracker_tenant_id_idx.
offers.offer_targeting_rule (21 cols, 6-column autonomy) — wholly NEW table, no v1 precedent
Built as 21 columns: the design proposal's own inline column walk stated "17" but itemized 19 named columns — a Finding-6-class arithmetic slip the design's own verification pass didn't catch on this particular table. Built here with the full standard triple (created_at/updated_at/deleted_at), matching every sibling table in this module rather than omitting soft-delete/update-tracking to force-fit a stale total — 19 + 2 (soft-delete/update-tracking already counted once each in the 19, so effectively created_at+updated_at+deleted_at complete the standard triple) = 21, disclosed at build time.
6 discriminator columns (segment_definition_id, category_ref, engagement_threshold, geography_ref, min_visit_frequency, attribute_ref — renamed from growing_zone_code 2026-07-18, Phase 2, PROJECT_DECISIONS #70), one per rule_type branch — chk_offer_targeting_rule_type_coherence is a CHECK the design's own verification flagged as missing in the first draft: zero coherence enforcement for this 6-way discriminator, a real regression vs. every other polymorphic-by-type table in this codebase's own established discipline.
segment_definition_id is a plain FK (not composite) — crm.customer_segment_definition lacks UNIQUE (id, tenant_id) entirely (live-confirmed). The cross-tenant-integrity trigger (trg_offer_targeting_rule_validate_segment, mirroring pricing.trg_price_rule_validate_supersession's own precedent) is written in the migration, built now rather than deferred.
Tenant-scoped. RLS enabled —
offer_targeting_rule_tenant_isolation. Soft delete:deleted_at.updated_at: trigger-maintained viaplatform.set_updated_at(). Autonomy: 6-column pack (5-column review seam +created_by_actor_id, nodecision_provenance).
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
deleted_at |
timestamptz | nullable | — | Soft delete |
offer_id |
UUID | NOT NULL | — | Composite FK → offers.offer |
rule_type |
text | NOT NULL | — | CHECK IN (segment,category_affinity,engagement_level,geography,visit_frequency,attribute_match) — growing_zone renamed attribute_match 2026-07-18, Phase 2 (PROJECT_DECISIONS #70) |
segment_definition_id |
UUID | nullable | — | Plain FK → crm.customer_segment_definition. Cross-tenant integrity via trigger, not composite FK. Required IFF rule_type='segment' |
category_ref |
text | nullable | — | Loose ref — category code, not a locked table. Required IFF rule_type='category_affinity' |
engagement_threshold |
numeric | nullable | — | Required IFF rule_type='engagement_level' |
geography_ref |
text | nullable | — | Loose ref — geography code/region identifier. Required IFF rule_type='geography' |
attribute_ref |
text | nullable | — | Renamed from growing_zone_code 2026-07-18, Phase 2 (PROJECT_DECISIONS #70); FK to shared.climate_zone.code DROPPED — now a generic, unFK'd loose reference. Required IFF rule_type='attribute_match' |
min_visit_frequency |
integer | nullable | — | Required IFF rule_type='visit_frequency' |
is_fallback |
boolean | NOT NULL | false |
|
priority |
integer | NOT NULL | 0 |
|
created_by_actor_id |
UUID | nullable | — | FK → identity.actor |
automation_source |
text | NOT NULL | 'human' |
CHECK IN (human,agent,system,seed) |
review_status |
text | NOT NULL | 'not_required' |
CHECK IN (not_required,pending,approved,rejected) |
review_reason |
text | nullable | — | |
reviewed_by_actor_id |
UUID | nullable | — | FK → identity.actor |
reviewed_at |
timestamptz | nullable | — |
CHECK constraints (4): chk_offer_targeting_rule_rule_type; chk_offer_targeting_rule_type_coherence (6-branch, one discriminator column required per rule_type, all 5 others forbidden — the Lens-D-flagged fix; the growing_zone/growing_zone_code branch was renamed attribute_match/attribute_ref 2026-07-18, Phase 2, PROJECT_DECISIONS #70); chk_offer_targeting_rule_automation_source; chk_offer_targeting_rule_review_status.
Indexes (3): PK on id; offer_targeting_rule_tenant_id_idx (partial); offer_targeting_rule_offer_id_idx (partial); offer_targeting_rule_is_fallback_idx (partial). No dedicated UNIQUE (id, tenant_id) on this table — nothing yet needs to reference it via composite FK.
Renamed 2026-07-18, Phase 2 (Nursery Vertical Extraction, PROJECT_DECISIONS #70) — the generic offer engine must not structurally depend on any vertical's reference data, so the FK to shared.climate_zone was dropped entirely; attribute_ref is now a generic loose reference (no FK), populated by whichever vertical needs it (a nursery tenant populates it with a nursery_ref.climate_zone code). A dedicated nursery.offer_targeting_extension child table was considered and rejected — zero live data, zero live consumers (no OffersService exists), and it would need its own new UNIQUE(id,tenant_id) prerequisite for no present benefit.
offers.customer_discount_exposure (9 cols) — maintained rolling-period counter
An atomic-UPSERT-maintained counter (mirrors ai.agent_usage_period's own precedent, applied correctly here per the design's own verification — this table was already right in the first draft). Tracks a consumer's total discount exposure per rolling period, for future fraud/cap/RFM analysis — not itself a redemption record (offer_redemption remains the source of truth; this is a reconcilable rollup).
Tenant-scoped. RLS enabled —
customer_discount_exposure_tenant_isolation. No soft delete.updated_at: trigger-maintained viaplatform.set_updated_at(). No autonomy pack.
| Column | Type | Nullable | Default | Notes |
|---|---|---|---|---|
id |
UUID | NOT NULL | gen_random_uuid() |
PK |
tenant_id |
UUID | NOT NULL | — | FK → platform.tenant |
consumer_id |
UUID | NOT NULL | — | FK → consumer.consumer (bare) |
period_start |
timestamptz | NOT NULL | — | |
period_end |
timestamptz | NOT NULL | — | CHECK > period_start |
total_discount_cents |
bigint | NOT NULL | 0 |
Atomic-UPSERT maintained (INSERT ... ON CONFLICT ... DO UPDATE SET total_discount_cents = table.total_discount_cents + EXCLUDED....), mirrors ai.agent_usage_period |
redemption_count |
integer | NOT NULL | 0 |
Same atomic-UPSERT maintenance |
created_at |
timestamptz | NOT NULL | now() |
|
updated_at |
timestamptz | NOT NULL | now() |
Trigger-maintained |
CHECK constraints (3): chk_customer_discount_exposure_total_nonneg; chk_customer_discount_exposure_count_nonneg; chk_customer_discount_exposure_period_order.
Indexes (2): PK on id; customer_discount_exposure_tenant_consumer_period_unique (UNIQUE, tenant_id+consumer_id+period_start); customer_discount_exposure_consumer_id_idx.
The atomic guardrail+sync trigger
offers.check_and_sync_offer_budget() — merges several concerns into one atomic BEFORE INSERT statement on offer_redemption: (a) the margin-floor check with zero-cost fail-closed rejection, (b) the max_discount_percent/max_discount_amount_per_order guardrail-ceiling check, (c) the budget-cap + max_redemptions check + offer.budget_used_cents/.redemption_count/offer_code.redeemed_count sync, (d) — added by the same-day reopen, PROJECT_DECISIONS #60 — the proportional/cumulative-cap reversal check via the new offer_redemption_reversal_tracker table, and (e) — added by a 2026-07-19 gap-validation fix pass — the offer.max_per_consumer cap, positioned AFTER the offer-row-locking UPDATE (see its own paragraph below). All evaluated inside the same row-locking UPDATE(s), so no concurrent-insert window exists between "checked" and "applied" (the same concurrency-race pattern rewards.sync_loyalty_account_balance() closes).
The margin-percent arithmetic completes the design proposal's own trigger snippet, which stopped at the zero-cost guard without specifying the actual comparison — completed here as: realized_margin = (post_discount_price - avg_cost) / post_discount_price * 100, rejected if below min_margin_percent. The code block below is the function's actual current, live body (from the same-day reopen's own full CREATE OR REPLACE) — it also incorporates 2 earlier same-day fixes this doc had never previously reflected (a disclosed, pre-existing doc-staleness gap caught while updating this section for the proportional-reversal fix, not itself part of PROJECT_DECISIONS #60): the max_discount_percent basis was corrected from the offer's own min_purchase_cents (routinely NULL, silently defeating the guard) to the actual sale amount being discounted (pos.sale_line.charged_amount_minor_units when line-scoped, else pos.sale.total_minor_units), and redemption_count/offer_code.redeemed_count capping against max_redemptions was added — both from packages/db/migrations/20260711010000_consumer_rewards_offers_verify_fixes.sql, itself predating this reopen.
The reversal treatment, specifically (PROJECT_DECISIONS #60): the 3 guardrail checks (margin floor, both discount ceilings) now explicitly gate on NEW.redemption_type = 'redeem' — previously they relied on a reversal's negative discount_amount_applied_cents happening to make each comparison vacuously true, which is fragile, not a real guarantee. A 'reverse' row instead lazily creates a row in offer_redemption_reversal_tracker for the reversed_redemption_id being reversed (snapshotting its discount_amount_applied_cents; the lazy-create SELECT requires redemption_type='redeem' on the source, so a reversal of a reversal finds no eligible original), then an atomic UPDATE ... WHERE total_reversed_cents + abs(:amt) <= original_discount_amount_cents ... RETURNING both caps and increments the cumulative total in one row-locking statement — this is what closes BUG B (the $10-redemption/$1,000-fabricated-reversal live bug). budget_used_cents still adjusts by the exact (possibly partial) amount on every redeem/reverse; redemption_count/redeemed_count (integer counts, not dollar amounts) only decrement by 1 when a reversal's own cumulative total exactly equals the original discount amount (v_fully_reversed), preventing a fractional count change on a merely-partial reversal.
CREATE OR REPLACE FUNCTION offers.check_and_sync_offer_budget()
RETURNS trigger LANGUAGE plpgsql AS $$
DECLARE
v_offer offers.offer%ROWTYPE;
v_avg_cost_cents bigint;
v_charged_amount bigint;
v_basis_amount bigint;
v_post_discount_price numeric;
v_realized_margin_percent numeric;
v_new_budget bigint;
v_new_redemption_count integer;
v_rows int;
v_new_total_reversed bigint;
v_original_discount bigint;
v_fully_reversed boolean := false;
v_redemption_count_delta integer;
v_active_redemption_count integer;
BEGIN
SELECT * INTO v_offer FROM offers.offer WHERE id = NEW.offer_id;
-- The 3 guardrail checks below are only meaningful for a genuine 'redeem'
-- -- explicitly gated now, rather than relying on a reversal's negative
-- discount_amount_applied_cents happening to make each comparison
-- vacuously true (fragile, not a real guarantee).
IF NEW.redemption_type = 'redeem' THEN
IF v_offer.max_discount_percent IS NOT NULL THEN
-- Basis is the actual sale amount being discounted (the sale_line's
-- own charged amount when line-scoped, else the full sale total),
-- NEVER min_purchase_cents (that column is the offer's OWN
-- eligibility threshold, not what was actually charged, and is
-- routinely NULL).
IF NEW.sale_line_id IS NOT NULL THEN
SELECT charged_amount_minor_units INTO v_basis_amount FROM pos.sale_line WHERE id = NEW.sale_line_id;
ELSE
SELECT total_minor_units INTO v_basis_amount FROM pos.sale WHERE id = NEW.sale_id;
END IF;
IF v_basis_amount IS NULL OR v_basis_amount = 0 THEN
RAISE EXCEPTION 'cannot verify max_discount_percent against an unknown or zero sale amount for offer %', NEW.offer_id;
END IF;
IF NEW.discount_amount_applied_cents::numeric / v_basis_amount * 100 > v_offer.max_discount_percent THEN
RAISE EXCEPTION 'discount exceeds max_discount_percent for offer %', NEW.offer_id;
END IF;
END IF;
IF v_offer.max_discount_amount_per_order IS NOT NULL
AND NEW.discount_amount_applied_cents > v_offer.max_discount_amount_per_order THEN
RAISE EXCEPTION 'discount exceeds max_discount_amount_per_order for offer %', NEW.offer_id;
END IF;
IF v_offer.min_margin_percent IS NOT NULL AND NEW.sale_line_id IS NOT NULL THEN
SELECT iv.avg_cost_cents, sl.charged_amount_minor_units
INTO v_avg_cost_cents, v_charged_amount
FROM pos.sale_line sl JOIN inventory.item_variant iv ON iv.id = sl.item_variant_id
WHERE sl.id = NEW.sale_line_id;
IF v_avg_cost_cents IS NULL OR v_avg_cost_cents = 0 THEN
RAISE EXCEPTION 'cannot verify margin floor against a zero-cost item for offer % — requires human review', NEW.offer_id;
END IF;
v_post_discount_price := GREATEST(v_charged_amount - NEW.discount_amount_applied_cents, 0);
IF v_post_discount_price = 0 THEN
RAISE EXCEPTION 'discount for offer % would reduce the realized price to zero, failing the margin floor', NEW.offer_id;
END IF;
v_realized_margin_percent := (v_post_discount_price - v_avg_cost_cents) / v_post_discount_price * 100;
IF v_realized_margin_percent < v_offer.min_margin_percent THEN
RAISE EXCEPTION 'discount for offer % would drop realized margin to % (floor is %)', NEW.offer_id, round(v_realized_margin_percent, 2), v_offer.min_margin_percent;
END IF;
ELSIF v_offer.min_margin_percent IS NOT NULL AND NEW.sale_line_id IS NULL THEN
RAISE EXCEPTION 'offer % has a margin floor but this redemption has no sale_line_id to verify cost against', NEW.offer_id;
END IF;
END IF;
IF NEW.redemption_type = 'reverse' THEN
-- Lazily create the tracker row for the original 'redeem' being
-- reversed (snapshotting its discount amount). The SELECT requires
-- redemption_type='redeem' -- you cannot reverse a reversal.
INSERT INTO offers.offer_redemption_reversal_tracker (tenant_id, redemption_id, original_discount_amount_cents)
SELECT NEW.tenant_id, NEW.reversed_redemption_id, r.discount_amount_applied_cents
FROM offers.offer_redemption r
WHERE r.id = NEW.reversed_redemption_id AND r.tenant_id = NEW.tenant_id AND r.redemption_type = 'redeem'
ON CONFLICT (tenant_id, redemption_id) DO NOTHING;
-- Atomic cap + increment (closes BUG B: a $1,000 reversal of a $10
-- redemption is now rejected, since 0 + 1000 > 10). A concurrent second
-- reversal against the same original blocks on this row's lock.
UPDATE offers.offer_redemption_reversal_tracker
SET total_reversed_cents = total_reversed_cents + abs(NEW.discount_amount_applied_cents)
WHERE tenant_id = NEW.tenant_id
AND redemption_id = NEW.reversed_redemption_id
AND total_reversed_cents + abs(NEW.discount_amount_applied_cents) <= original_discount_amount_cents
RETURNING total_reversed_cents, original_discount_amount_cents INTO v_new_total_reversed, v_original_discount;
GET DIAGNOSTICS v_rows = ROW_COUNT;
IF v_rows = 0 THEN
RAISE EXCEPTION 'reverse redemption for reversed_redemption_id % would exceed the cumulative reversible discount, references a non-redeem row, or was not found', NEW.reversed_redemption_id;
END IF;
v_fully_reversed := (v_new_total_reversed = v_original_discount);
END IF;
v_redemption_count_delta := CASE
WHEN NEW.redemption_type = 'redeem' THEN 1
WHEN NEW.redemption_type = 'reverse' AND v_fully_reversed THEN -1
ELSE 0
END;
-- Single atomic conditional UPDATE: budget releases/consumes by the exact
-- (possibly partial) discount amount on every redeem/reverse;
-- redemption_count only moves on a genuine redeem or a FULLY-exhausted
-- reverse (never fractionally).
UPDATE offers.offer
SET budget_used_cents = budget_used_cents + NEW.discount_amount_applied_cents,
redemption_count = redemption_count + v_redemption_count_delta
WHERE id = NEW.offer_id
AND (budget_cents IS NULL OR budget_used_cents + NEW.discount_amount_applied_cents <= budget_cents
OR NEW.redemption_type = 'reverse')
AND (max_redemptions IS NULL OR v_redemption_count_delta <= 0
OR redemption_count + v_redemption_count_delta <= max_redemptions)
RETURNING budget_used_cents, redemption_count INTO v_new_budget, v_new_redemption_count;
GET DIAGNOSTICS v_rows = ROW_COUNT;
IF v_rows = 0 THEN
RAISE EXCEPTION 'offer % budget or max_redemptions cap would be exceeded by this redemption', NEW.offer_id;
END IF;
-- gap-validation fix (2026-07-19): max_per_consumer enforcement, AFTER the
-- row-locking UPDATE above -- see the dedicated paragraph below this code
-- block for the full rationale and the documented slot-freeing rule.
IF NEW.redemption_type = 'redeem' AND v_offer.max_per_consumer IS NOT NULL AND NEW.consumer_id IS NOT NULL THEN
SELECT count(*) INTO v_active_redemption_count
FROM offers.offer_redemption r
WHERE r.tenant_id = NEW.tenant_id
AND r.offer_id = NEW.offer_id
AND r.consumer_id = NEW.consumer_id
AND r.redemption_type = 'redeem'
AND NOT EXISTS (
SELECT 1 FROM offers.offer_redemption_reversal_tracker t
WHERE t.tenant_id = r.tenant_id
AND t.redemption_id = r.id
AND t.total_reversed_cents = t.original_discount_amount_cents
);
IF v_active_redemption_count + 1 > v_offer.max_per_consumer THEN
RAISE EXCEPTION 'offer % max_per_consumer (%) would be exceeded for consumer % (% active redemptions already)',
NEW.offer_id, v_offer.max_per_consumer, NEW.consumer_id, v_active_redemption_count;
END IF;
END IF;
IF NEW.offer_code_id IS NOT NULL THEN
UPDATE offers.offer_code
SET redeemed_count = redeemed_count + v_redemption_count_delta
WHERE id = NEW.offer_code_id
AND (max_redemptions IS NULL OR v_redemption_count_delta <= 0
OR redeemed_count + v_redemption_count_delta <= max_redemptions);
GET DIAGNOSTICS v_rows = ROW_COUNT;
IF v_rows = 0 THEN
RAISE EXCEPTION 'offer_code % max_redemptions cap would be exceeded by this redemption', NEW.offer_code_id;
END IF;
END IF;
RETURN NEW;
END;
$$;
CREATE TRIGGER trg_offer_redemption_check_and_sync_budget
BEFORE INSERT ON offers.offer_redemption
FOR EACH ROW EXECUTE FUNCTION offers.check_and_sync_offer_budget();
max_per_consumer enforcement — a real gap found, then closed WITHOUT the tracker table this build's own original OPEN_ITEMS row assumed would be needed (gap-validation fix, 2026-07-19). offer.max_per_consumer was declared at the original 2026-07-11 lock but never referenced by this function — confirmed unenforced via pg_get_functiondef during a 2026-07-19 gap-validation read-only pass (Part B, B7). That pass's own review claim also cited a customer_discount_exposure COLUMN as evidence of a lighter-weight existing mechanism; this was corrected during the same-day fix pass — customer_discount_exposure is a separate TABLE (confirmed via \dt offers.*, 9 cols, 0 rows), not a column, and it plays no role in this enforcement. The original 2026-07-11 lock-gate OPEN_ITEMS row for this gap explicitly warned that "a live COUNT(*) query without [a lock] would reintroduce the exact concurrency race" and recommended a dedicated per-(offer,consumer) counter table instead (mirroring offer_redemption_reversal_tracker's own shape). The fix above closes the gap WITHOUT that new table: the COUNT(*) (v_active_redemption_count) is positioned AFTER the row-locking UPDATE offers.offer earlier in this same function — that lock already serializes every concurrent redemption of one offer (a second concurrent INSERT's own UPDATE blocks on the offer row until the first transaction commits and releases it), so a count taken after it always observes every already-committed sibling row, making this COUNT(*) race-safe by construction, not by accident. Live-reproduced with a genuine 3-way concurrent race: 3 backgrounded psql processes, each wrapped in its own BEGIN/pg_sleep(0.3)/INSERT/COMMIT, targeting the same consumer against an offer with max_per_consumer=2 — exactly 2 of 3 committed, 1 rejected with the exact exception text above, confirming the count-after-lock placement holds under real overlap, not just sequential execution.
Slot-freeing rule (a genuine design decision, not just an implementation detail). A FULLY-reversed prior redemption frees its slot — the NOT EXISTS subquery reuses the reversal tracker's own total_reversed_cents = original_discount_amount_cents equality (the same signal v_fully_reversed computes for the CURRENT row being reversed, but here re-derived per HISTORICAL sibling row, since v_fully_reversed itself is scoped only to whichever row is being inserted right now) — while a PARTIALLY-reversed redemption still counts against the cap (consistent with redemption_count itself only decrementing on a full reversal, never a partial one). The check applies to genuine 'redeem' rows only (NEW.redemption_type = 'redeem'); reversal rows are exempt entirely, matching the same gating already used for the 3 guardrail checks above. NEW.consumer_id IS NOT NULL is a defensive guard, not a live branch: offer_redemption.consumer_id is NOT NULL at the table level today, so no anonymous-redemption row can exist under the current schema — disclosed in OPEN_ITEMS as forward-compatible dead code, not assumed reachable. Migration: packages/db/migrations/20260719000002_offers_max_per_consumer_enforcement.sql. Regression tests: offers-schema.spec.ts section L (L1–L6).
The zero-cost fail-closed guard is the AI margin-bypass fix: if a margin floor is set on the offer but the linked item_variant.avg_cost_cents is NULL or zero, the redemption is REJECTED outright ("requires human review") rather than silently passing the margin check against an unknown or zero cost — a zero/unknown cost could otherwise let an AI-proposed discount slip through with no real margin verification at all. The magnitude fail-closed guard (PROJECT_DECISIONS #60) is the reversal-side counterpart: a 'reverse' row whose cumulative claimed amount would exceed its original redemption's own discount_amount_applied_cents is REJECTED outright by the offer_redemption_reversal_tracker's own capped UPDATE returning zero rows — closing the live bug where a $1,000 reversal of a $10 redemption previously succeeded by silently consuming an unrelated redemption's legitimate budget.
The segment cross-tenant guard
trg_offer_targeting_rule_validate_segment, mirroring pricing.trg_price_rule_validate_supersession exactly — a plain FK, DB-enforced same-tenant-or-global guard, needed because segment_definition_id cannot be a composite FK (crm.customer_segment_definition lacks the UNIQUE (id, tenant_id) prerequisite).
CREATE OR REPLACE FUNCTION offers.validate_offer_targeting_rule_segment()
RETURNS trigger AS $$
BEGIN
IF NEW.segment_definition_id IS NOT NULL THEN
IF NOT EXISTS (
SELECT 1 FROM crm.customer_segment_definition
WHERE id = NEW.segment_definition_id
AND (tenant_id = NEW.tenant_id OR tenant_id IS NULL)
) THEN
RAISE EXCEPTION 'offer_targeting_rule.segment_definition_id must reference a customer_segment_definition row belonging to the same tenant, or a global (tenant_id IS NULL) segment';
END IF;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER trg_offer_targeting_rule_validate_segment
BEFORE INSERT OR UPDATE OF segment_definition_id, tenant_id ON offers.offer_targeting_rule
FOR EACH ROW EXECUTE FUNCTION offers.validate_offer_targeting_rule_segment();
Allows a NULL-tenant (global/built-in) segment OR one belonging to the same tenant; rejects a different tenant's own segment.
offers — Design Patterns Summary
Column-count reconciliation
| Table | Cols |
|---|---|
offer |
47 |
offer_code |
13 |
offer_assignment |
15 |
offer_redemption |
15 |
offer_targeting_rule |
21 |
customer_discount_exposure |
9 |
offer_redemption_reversal_tracker |
7 |
| Total | 127 |
Counted directly from the applied CREATE TABLE/ALTER TABLE statements across all migrations to date: packages/db/migrations/20260711000000_consumer_rewards_offers.sql (the original build, 6 tables), 20260711010000_consumer_rewards_offers_verify_fixes.sql (same-day lock-gate fix pass, +1 col — offer.redemption_count, a pre-existing drift this doc had never previously reflected, disclosed above), 20260711030000_rewards_offers_proportional_clawback.sql (the same-day reopen, PROJECT_DECISIONS #60, +1 table / +7 cols — offer_redemption_reversal_tracker), and 20260720000006_offers_reopen_buy_x_get_y.sql + the same-day fix 20260720000008_offers_fix_bxgy_discount_type_coherence.sql (PROJECT_DECISIONS #74, gap B6, +4 cols on offer — bxgy_qualifying_qty/bxgy_reward_qty/bxgy_reward_variant_id/bxgy_reward_discount_pct, no new table). 7 tables, 127 columns — up from 123 (originally-recorded 6/115 at the 2026-07-11 build).
Triggers, full list (3 functions, 7 trigger objects)
| Trigger | Table | Fires | Function |
|---|---|---|---|
set_updated_at |
offer, offer_code, offer_assignment, offer_targeting_rule, customer_discount_exposure, offer_redemption_reversal_tracker |
BEFORE UPDATE |
platform.set_updated_at() (shared, reused) |
trg_offer_redemption_append_only |
offer_redemption |
BEFORE UPDATE OR DELETE |
platform.reject_append_only_mutation() (shared, reused) |
trg_offer_redemption_check_and_sync_budget |
offer_redemption |
BEFORE INSERT |
offers.check_and_sync_offer_budget() (reopened 2026-07-11, PROJECT_DECISIONS #60 — proportional/cumulative-cap reversal + closes the independent magnitude-check bug; reopened again 2026-07-19, gap-validation fix — max_per_consumer enforcement, no schema change; see above) |
trg_offer_targeting_rule_validate_segment |
offer_targeting_rule |
BEFORE INSERT OR UPDATE OF segment_definition_id, tenant_id |
offers.validate_offer_targeting_rule_segment() (new — see above) |
Service layer
No OffersService yet — schema-only this pass, same pattern as every other module's deferred service layer at build time. The offer-generating AI agent path (an identity.agent_identity + identity.agent_duty_grant for offers:offer:propose) needs zero new AI infrastructure — it reuses the existing A5 agent-authority passport mechanism verbatim; only the guardrail enforcement itself (the trigger above) is built this pass.
JSONB columns
offer.eligibility_json, offer.decision_provenance (project-wide reason/evidence/confidence/memory_refs convention), offer.excluded_categories, offer_assignment.eligibility_snapshot_json.
Open items carried forward
- No
OffersServiceyet — offer matching/eligibility evaluation, targeting-rule application, and the actual AI-proposal flow are all unbuilt. offer_targeting_rulehas no dedicatedUNIQUE (id, tenant_id)— nothing yet needs to reference it via composite FK; add if a future consumer needs one.chk_offer_ai_requires_guardrail's ceiling requirement is a floor, not a guarantee of correctness — an AI-touched offer must carry SOME ceiling, but nothing validates the ceiling value itself is sane (e.g. a 100%max_discount_percenttechnically satisfies the CHECK).customer_discount_exposure's atomic-UPSERT maintenance is a documented service-layer build requirement — the schema only defines the shape and the uniqueness constraint the UPSERT relies on; nothing yet writes to this table.approvals.approval_request.source_module's CHECK widen to include'offers'— a cross-module side effect of this migration (see module intro above), landing inapprovals, notoffersitself; not restated inapprovals' own docs by this pass.- This proportional-reversal fix (PROJECT_DECISIONS #60) is Pass 1 of a 2-pass effort — Pass 2 builds the
returnsmodule againstreturns-module-design-proposal-2026-07-11.md, which is the actual consumer of this capability (a return needs to claw back a proportional share of loyalty points/offer discount).