---
url: /reference/CATALOG_OPTIONS_V2.md
---
# Catalog Options and Modifiers v2

**What this is.** The design of the second generation of catalog options
("modifiers"), the effective-offer rule shared by every reader, the
ownership fixes that must precede Sales and Pricing, the offline outline and
the structured variant attributes. It extends
[`CATALOG_USE_CASES.md`](CATALOG_USE_CASES.md) (C12, C13, C17–C20) and plans
the slices **K9a to K15d** and **K16**. Built, each with its *As built* section: K9a,
K9b and K9c (§4.1), K9f (§4.2), K9g (§4.3), K9d (§4.4), K9e (§5.1), the
satisfiability by units of K9h (§4.2, *As built (F1)*), K10a, K10b and K10c
(§6.2), D1 (§18.1), B1 (§19.2), B2 (§20.2), M1 (§21.2), the
standardization after M1 (§21) M2a (§21, "M2a as built"), K16 (§21, "K16 as built"), K14c ("K14c as built"), K14d ("K14d as built"), K15a ("K15a as built"), K12a ("K12a as built") and K12b ("K12b as built"). What is still a design, and in which order,
is in §10 and its coverage snapshot (§10.1).

**Reconciled with the architect's final review** (owner's architect,
*Ninaku Core — revisión de Catálogo, contratos, SQL y offline*, 2026-10-07,
reviewed commit `e0e9bdf`; findings H1–H8 and the work order of its §13).
Where this document follows the review it says so; where it decides
something the review left open, the decision is marked; where it diverges,
§3 says why.

**Lead decision.** Work follows the architect's order: **H1** stable offer
identities and retirement first (K9a–K9c), then **H2/H3** quantified
selection and the effective offer identity in the contract (K9d–K9e), then
**H4** variant overrides (K9f–K9h), then **H7** SQL ownership (K10a–K10c),
then the minimal Sales/Pricing integration proof (K11, which also measures
**H8**), then **H5** offline (K12a–K12c), then **H6** structured variant
attributes (K13a–K13b), then option and master-data specializations
(K14, K15), and only then menus and bundles. **Sales and Pricing must not
reference offers before K9b ships, and must not call `admit` before K9e
ships.**

**First implementation slice: K9a** — one expand-only migration that adds
the `retired` offer status, and every reader of offers taught to ignore a
retired offer, with no writer of `retired` yet (§4.1, §10).

**Owner direction (2026-10-07, translated).** "The old backend was only a
study. In this new backend we must do things correctly and add all of these
things, taking into account inventory and everything else: pricing,
discounts, all of it. It must be super powerful, even for other sectors.
Catalog is super important." Kept as *Decided*: per-variant modifier offers
in v1; a quantity-aware admission; no coupling between modules and no
duplicated data (Catalog never stores prices, stock or tax).

**Status words.** *Decided*: taken by the owner's direction, by an
executable authority (the canonical SQL), or already built; the owner only
needs to object. *Recommended*: the designer's default; review may change
it. *Proposed*: a shape kept for later, not scheduled. Open owner questions
are in §15.

**Ground truth.** The canonical SQL under `database/canonical/`
(`0080_catalog.sql`, `0090_pricing.sql`, `0100_commerce.sql`,
`0110_sales.sql`, `0140_operations.sql`, `0150_kitchen.sql`,
`0170_inventory.sql`, `0180_production.sql`, `0260_tax.sql`,
`0350_integrations.sql`, `0360_events.sql`, `0370_sync.sql`),
`database/access/runtime_grants.sql`, `src/modules/catalog/`,
`src/modules/organization/`, `docs/reference/ARCHITECTURE.md`; the feature
branch at migration `0083`, baseline revision 93. Line numbers refer to
these files in this branch. Legacy backend facts come from the owner's study
of the old system (briefing of 2026-10-07): lessons, never code to copy.

***

## Quick path

1. **H1, K9a–K9c:** offers keep their UUID for ever; removing a group
   *retires* the offer (a new `retired` status), never deletes it;
   suspending stays `inactive`; then `DELETE` on offers is revoked.
2. **H2/H3, K9d–K9e:** selections travel as `{ optionId, quantity }`;
   groups are judged on the sum of units; `admit` answers the effective
   offer (`itemModifierGroupId`, `modifierGroupId`, `source`, min, max).
3. **H4, K9f–K9h:** one effective-offer rule for the till, the search, the
   scan and `admit`: an active variant override replaces the item offer, an
   inactive variant override opts the variant out, no override inherits.
4. **H7, K10a–K10c:** Catalog stops naming Organization and Platform tables;
   Organization stops writing Platform's table.
5. **K11:** a minimal Sales/Pricing proof over the real FKs, and the H8
   admission budget measured on the real sale transaction.
6. **H5, K12a–K12c; H6, K13a–K13b; then K14, K15; then menus and bundles.**

***

## 1. Why a v2

| Evidence | Source | Consequence |
|---|---|---|
| Saving an item's offers deletes every item-level offer and re-inserts the list with new UUIDs | `src/modules/catalog/infrastructure/modifiers/modifier-group.queries.ts:158-175`; `modifier-group.repository.ts:338-344` | Sales and Pricing reference offers with no cascade (`0110_sales.sql:1229-1237`, `0090_pricing.sql:470-478`): once referenced, an edit fails (architect H1) |
| A required offer with `status <> 'active'` makes the item unconfigurable | `src/modules/catalog/infrastructure/reads/sellable-rule.queries.ts:55-73` | Retiring by setting `inactive` would hide the item: "no longer offered" and "offered but unavailable" need two states (architect H1) |
| `admit` takes `modifierOptionIds: string[]` and refuses repeats | `src/modules/catalog/contracts/catalog-sellable.contract.ts:68-81`; `application/sellable/sellable-request.ts:27-35`, `:65-75` | "Cheese × 2" cannot be expressed (architect H2) |
| Catalog counts distinct options; Sales sums `quantity` | `src/modules/catalog/domain/sellable/sellability.ts:123-149`; `0110_sales.sql:4229-4247` | The same rule means two things (architect H2; the architect executed `judgeSelection` and got `selection_below_minimum` for cheese with minimum 2) |
| The snapshot carries `modifierGroupId`, not the offer id | `catalog-sellable.contract.ts:26-33`; `infrastructure/sellable/sellable.queries.ts:31-53` | Sales cannot fill `item_modifier_group_id` (`0110_sales.sql:1184`) without reading Catalog's tables (architect H3) |
| Admission and the till read only item-level offers | `sellable.queries.ts:24-29`; `infrastructure/till/till-read.queries.ts:111`, `:139` | Sales already prefers variant offers (`0110_sales.sql:4120-4134`, `:4200-4225`): the surfaces diverge (architect H4) |
| Catalog SQL names Organization and Platform tables | `infrastructure/items/item.queries.ts:84`; `sellable/sellable.queries.ts:69`, `:109`; `barcodes/barcode-lookup.queries.ts:18-22`; `assortment/assortment.queries.ts:3`, `:55`; `reads/catalog-read.queries.ts:49-50`; `reads/item-list.queries.ts:64`; `till/till-read.queries.ts:43`, `:187` | Against `ARCHITECTURE.md:218-239` ("Ningún módulo tiene permitido acceder directamente a ... SQL de otro módulo") (architect H7) |
| Organization writes `platform.business_unit_verticals` | `src/modules/organization/infrastructure/business-units/business-unit-vertical.repository.ts:22-45` (gone since K10c: the write is `src/modules/platform/infrastructure/business-unit-verticals/platform-business-unit-verticals.repository.ts`, §6.2) | Same rule (architect H7) |
| Legacy: sauce groups duplicated per size (×6, ×14, ×25); `is_required` contradicted `min`; 72 of 73 options priced 0; client-trusted prices | Owner briefing (legacy study) | Per-variant limits; required = `min > 0` (`0080_catalog.sql:1628`); free defaults are normal; prices always resolved server-side |

What the market does (verified sources in §19):

| Concept | Who | Used here as |
|---|---|---|
| Quantity per option; group limits on the sum; max may exceed the option count when quantities are allowed | Square `CatalogModifierList.allow_quantities`, `max_selected_modifiers` | OV5 |
| Per-item override of min/max, enabled, order, receipt visibility | Square `CatalogItemModifierListInfo` | Offers; per-offer overrides (K14) |
| Per-item override of one modifier's default and online visibility | Square `CatalogModifierOverride` | Offer defaults (exist); overrides (K14) |
| Text modifiers with maximum length and "text required" | Square `modifier_type = TEXT`, `max_length`, `text_required` | Text options (K14c) |
| Prefixes No / Add / Sub; required and allowed condiment groups with min and max | Oracle Simphony | Prefix hint (K14c) |
| Size and sequence pricing; portions with a price scale factor | Toast | Pricing's work; portions deferred |
| `minRequired`, `maxAllowed`, `showByDefault` | Clover | Same model |
| Modifiers separate from production instructions | Lightspeed K-Series | Prefix is a hint; effect is Production's |
| Charged once, per night, per person, per person per night | Mews | Pricing's charge basis, never Catalog |
| Up to 2,048 variants, 3 options per product | Shopify | Variants and structured attributes (H6), not modifiers |
| Combo choices with extra price | Odoo | Bundles (C22) |

***

## 2. What exists today (as built, revision 93)

| Fact | Where |
|---|---|
| `modifier_groups` (code, name, description, status) | `0080_catalog.sql:1366-1411` |
| `modifier_options` (kind `instruction` or `catalog_variant` with a target, `max_quantity` 1–999, `sort_order`, status) | `0080_catalog.sql:1470-1574` |
| `item_modifier_groups`, the **offer** (item, optional `item_variant_id`, group, min, max, sort order, status `active`/`inactive`); "required is derived from `min_selections > 0`" | `0080_catalog.sql:1622-1704` |
| One offer per (item, group) at item level and per (variant, group) at variant level, **every status included** | `item_modifier_groups_item_scope_uq`, `_variant_scope_uq`, `0080_catalog.sql:1706-1718` |
| Offer defaults with composite FKs to the offer and to an option of the same group | `0080_catalog.sql:4411-4488` |
| Runtime may `DELETE` offers (revoked by K9c, migration `0091`, revision 101) | `database/access/runtime_grants.sql:118` |
| The catalog version is bumped by statement triggers on 12 catalog tables (catalogs, items, item\_variants, item\_variant\_barcodes, categories, item\_categories, modifier\_groups, modifier\_options, item\_modifier\_groups, business\_unit\_catalogs, business\_unit\_items, item\_modifier\_group\_defaults) plus the row created with each organization | `0080_catalog.sql:4139-4272`, `:4174`, `:4480-4488` |
| The till answers at most 2,000 items, with no SKU and no barcodes | `src/modules/catalog/application/till/sellable-read.ts:7`; `CATALOG_USE_CASES.md:1450` |
| Admission shares the catalog version row, then the business unit row | `CATALOG_USE_CASES.md:1468` |
| Catalog publishes no domain event (no outbox write in `src/modules/catalog` outside test doubles) | `rg` over the module; `events.outbox_records` exists (`0360_events.sql:202`) |
| Already correct, **not defects**: `salesQuantityStep` is in the published snapshot and the till (`catalog-sellable.contract.ts:35-48`); preselections live on the offer (`item_modifier_group_defaults`) since migration `0080`, and `0082` dropped `modifier_options.is_default` | architect §12; `CATALOG_USE_CASES.md:1409-1417`, `:1498` |

***

## 3. Decisions

| # | Decision | Status | Alternatives weighed | Why |
|---|---|---|---|---|
| **OV1** | An offer is the link (item or variant) × group with its rules; its UUID is stable for ever: editing min, max, order or defaults updates the row in place; only a genuinely new link gets a new UUID | *Decided* (architect H1) | Delete and re-insert (today): breaks FKs | Sales, Commerce and Pricing reference offers |
| **OV2** | Offer status vocabulary becomes `active` | `inactive` | `retired`. **Retired** = no longer offered: kept only for references and history, ignored by every read, sellability and requirement check; re-offering the same group **revives the same row** (same UUID). **Inactive** = still offered but suspended (item level) or an explicit opt-out (variant level, OV9) | *Decided* (architect H1: "retirar ≠ suspender") | Retire as `inactive` (this document's first draft): `requiredGroupsSatisfiable` reads it as "required but unavailable" and hides the item | §4.1 |
| **OV3** | Retired rows **stay inside** the two partial unique indexes: one row per logical link for ever | *Decided* (designer), diverging from the instruction to exclude retired rows from the unique indexes | Exclude retired rows (index `WHERE status <> 'retired'`): a re-offer could insert a second row for the same link instead of reviving, breaking OV1, and two rows would compete for one Pricing key | Revival is deterministic only with the full index; a separate partial index serves the live reads (§4.1) |
| **OV4** | Preserving a UUID does not freeze historical rules: Sales freezes names, quantities and charges on its own rows (`0110_sales.sql:1187-1199`); Pricing keeps dated price rows (`0090_pricing.sql:442-443`); Catalog keeps only the current rule | *Decided* (architect note) | Catalog revisions of offers: no consumer needs them while the consumers snapshot | — |
| **OV5** | Group min/max count **units**: the sum of `quantity` over the offer's selections; `maxQuantity` bounds one option; quantity is an **integer count** 1–999 | *Decided*: the Sales SQL is the executable authority (`0110_sales.sql:1190`, `:1271-1274`, `:4097-4101`, `:4229-4247`) | (a) distinct options: contradicts Sales. (b) both rules in v1: no reader needs it yet | A separate `maxDistinctOptions` on the offer is *Proposed* (§4.4) |
| **OV6** | A measurable option amount (0.25 kg extra) is not a quantity; it would be a separate, unit-bearing value (§4.4) | *Proposed* | Decimal `quantity`: Sales stores `smallint`; "two nights" or "ten sessions" are counts or a vertical's validity, not measurable modifiers (architect §4) | — |
| **OV7** | A preselection is never an accepted selection: the sale command sends what the customer kept; `admit` never adds defaults | *Decided* (built in K8, `CATALOG_USE_CASES.md:1474`; architect §4) | — | — |
| **OV8** | The contract answers the **effective offer**: `itemModifierGroupId`, `modifierGroupId`, `source: 'item' \| 'variant'`, `minSelections`, `maxSelections`; Sales stores the offer `admit` returned, never one looked up later | *Decided* (architect H3) | Consumers query `catalog.item_modifier_groups`: breaks the module boundary | §5 |
| **OV9** | Effective offer of variant V for group G: an **active** variant override replaces the item offer; an **inactive** variant override is an **explicit opt-out** (G not offered on V, no minimum); a missing or retired override **inherits** the item offer; an inactive item offer is **suspended** (if required, the variant is not configurable). One SQL fragment serves the till, the search, the scan, `resolve` and `admit` | *Decided* (owner, via coordinator; the review left the inactive override open) | (a) inactive override inherits (Sales until migration `0090`): no way for one variant to drop an item-level group. (b) "offer only on the variants that have it" (first draft): fails when the item-level offer exists for many variants | Sales aligned (X13, migration `0090`) |
| **OV10** | Overrides replace, never add: the variant rule is not summed with the item rule | *Decided* (architect H4) | — | — |
| **OV11** | A required group is satisfiable when its minimum is at most the sum of the effective maximum quantities of its available options | *Decided* (architect functional review, F1; built ahead of K9h, §4.2 *As built (F1)*) | Count of options (before F1) | Same arithmetic as OV5 |
| **OV12** | Catalog reads of Organization and Platform go through owner-published surfaces: TypeScript contracts sharing the opaque `TransactionContext` by default, and, as a **documented normative exception**, owner-published SQL read functions for the single-statement hot reads; Organization's vertical activation calls a Platform contract | *Recommended* (architect H7) | (a) contracts only: the till read and `admit` would lose their one-statement snapshot and statement budgets (`CATALOG_USE_CASES.md:1453`). (b) leave as is: violates `ARCHITECTURE.md:219` | §6 |
| **OV13** | Option kinds: `instruction`, `catalog_variant`, new `text`; quantity-only = any option with `maxQuantity > 1` | *Recommended* | Text as a group type; generic `input` kind | §4.5 |
| **OV14** | Prefix (`add`, `extra`, `light`, `no`, `on_the_side`, `substitute`) is a hint on the option; never a sale-time dimension | *Recommended* | Toast/Oracle pre-modifiers: need a Sales column and a Pricing multiplier | §4.6 |
| **OV15** | Removed defaults are derived by `admit` (`removedDefaults`), not stored by Catalog | *Recommended* | Negative quantities: Sales refuses them | §4.7 |
| **OV16** | Defaults carry a quantity; per-offer option overrides exclude an option or lower its maximum, never raise it | *Decided*; default quantities built in K14a ("K14a as built") and the per-offer option limits in K14b ("K14b as built") | — | Sales checks the option's own `max_quantity` (`0110_sales.sql:4097`) |
| **OV17** | Runtime availability ("86") of options is Operations' | *Recommended* | A catalog status flip invalidates every till | `0140_operations.sql:4598-4622` |
| **OV18** | Modules and data stay decoupled: no new cross-schema FK; no copy of a neighbour's fact (price, stock, tracking, tax, discount, channel mapping); neighbours get data through contracts or their own immutable snapshots | *Decided* (owner) | — | §17 |
| **OV19** | Variant attributes are structured, neutral dimensions and values with persistent links; the matrix creates them; no `shirt_size` columns | *Decided* (architect H6) | Concatenated codes and names (today) | §8 |
| **OV20** | Offline replay accepts a sale made under an earlier context only on explicit evidence (§7.3) | *Recommended*; owner question Q4 | Re-admit against the current catalog: refuses legitimate past sales (architect H5) | — |
| **OV21** | `item_kind` is *Proposed* only, with named readers (§17.1) and the constraint that Catalog never decides kitchen, stock, tax or membership from it | *Proposed* (architect §12) | Add now | No reader exists yet |
| **OV22** | The admission line carries its quantity and Catalog judges it against the variant's sales quantity policy | *Recommended* | Leave to Sales | The policy is a catalog fact (`0080_catalog.sql:140-200`) |
| **OV23** | Portions and nested options: deferred with fixed shapes | *Proposed* | — | §4.8, §4.9 |
| **OV24** | The catalog stays abstract and is administered per business unit or per outlet without per-channel flags: the business unit stays the unit of assortment and an outlet may only hide items from it (K16), never sell what its unit does not list | *Decided* (owner) | — | §10 (K16), §21 ("K16 as built") |
| **OV25** | The till read (C17) sends only the categories that hold something the outlet sells now: an item whose primary category is the category or one of its descendants, with the ancestors that lead to it; there is no parameter to show the others, and the till shows its own message from what it receives | *Decided* (owner), built | Keep empty categories (the earlier decision, so a folder the owner just filled is not hidden before the till refreshes); a flag to show them | `CATALOG_USE_CASES.md` C17 |

***

## 4. Capability model

### 4.1 Offer identity, retirement and suspension (H1)

**States of an offer row.**

| State | Meaning | Sellability and reads | Sales/Commerce/Pricing see |
|---|---|---|---|
| `active` | offered | used | a valid target (their triggers require `active`, `0110_sales.sql:4090-4095`, `0100_commerce.sql:1975-1980`) |
| `inactive` at item level | **suspended**: still offered, temporarily unavailable | required → variant not configurable; optional → omitted | refused as a new selection; history intact |
| `inactive` at variant level | **opt-out**: group not offered on that variant (OV9) | group omitted for that variant, no minimum | no minimum; a selection through the item offer is refused as not offered (X13, migration `0090`) |
| `retired` | **no longer offered**; kept for references | ignored everywhere (never "unavailable", never required) | refused as a new selection; history intact |

**Transitions.** `active → retired` (the group left the list), `inactive →
retired` (same), `retired → active` (re-offered: revival, same UUID; its
defaults come from the request; the retired row's defaults were deleted at
retirement), `active ⇄ inactive` (suspend / resume; opt-out / opt-in at
variant level). A retired row is never deleted (K9c revokes `DELETE`).

**Schema (K9a, expand-only).** `ALTER TABLE catalog.item_modifier_groups
DROP CONSTRAINT item_modifier_groups_status_chk, ADD CONSTRAINT
item_modifier_groups_status_chk CHECK (status IN ('active', 'inactive',
'retired'))`. **Widening a CHECK is expand-safe:** every existing row still
satisfies it, the previous API never writes `retired`, and K9a ships no
writer of `retired`, so during the rollout no previous-API instance can
meet one. Indexes: the two scope indexes stay as they are (OV3); a new
`item_modifier_groups_live_idx (organization_id, item_id, item_variant_id,
sort_order) WHERE status <> 'retired'` serves the live reads. The retired
offer keeps the FKs of `item_modifier_group_defaults` valid (none remain:
retirement deletes them first).

**Queries to fix (K9a: readers; K9b: writer).**

| Query | Today | Change |
|---|---|---|
| `requiredGroupsSatisfiable` | `ro.status <> 'active'` makes a required offer unsatisfiable (`sellable-rule.queries.ts:62`) | Exclude `retired` rows before judging; keep `inactive` (item level) as "required but suspended". K9f replaces it with the effective-offer fragment |
| `itemLevelOffer` / `OFFERS` / `OFFERED_OPTION_IDS` | `status = 'active'` (`sellable.queries.ts:24-60`) | Unchanged result (retired is not active); named explicitly in tests |
| Till groups and items | `o.status = 'active'` (`till-read.queries.ts:111-112`, `:139-140`) | Unchanged result; test that a retired offer never appears |
| Item read (C3) and the offer audit value | read every item-level row | Exclude `retired` |
| Where-used (`modifier-group-offers.queries.ts:53-65`) | every item-level row | Exclude `retired` |
| `DELETE_OFFERS` / `INSERT_OFFERS` (`modifier-group.queries.ts:158-175`) | delete all, insert all | K9b: `UPDATE` kept links in place (rules, order, status `active`), `UPDATE ... SET status = 'retired'` for removed links (after `DELETE` of their defaults), revive retired links by (item, group), `INSERT` only new links |
| Defaults removal on option/group/variant retirement (`modifier-group.queries.ts:194-199`) | delete from every offer | Unchanged |

**As built (K9a, migration `0084_catalog_offers_retired`, baseline
revision 94).** The CHECK is widened and `item_modifier_groups_live_idx` is
added exactly as above; the two scope indexes are unchanged (OV3). Three
readers now skip `retired`: `requiredGroupsSatisfiable` (so the till lists,
the search, the scan, `resolve` and `admit` keep an item sellable when its
required group is retired, and still refuse it while the group is
suspended), `ITEM_OFFERS` (the item read C3, hence the offer audit value and
the comparison of the offer command) and the where-used read. The till and
contract offer lists already read only `active`. No statement writes
`retired`: the offer command still deletes and re-inserts every item-level
row, retired ones included, so a group re-offered over a retired row gets a
new row until K9b. Proofs: `database/tests/contracts/catalog-offer-retired-contracts.ts`
(`NINAKU_CATALOG_OFFER_RETIRED_OK`) and the C13 rows of
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md).

**As built (K9b, no migration, baseline revision 95).** The offer command
writes the item-level offers in place, in four statements under the item
lock: it deletes the defaults of the item's offers, retires (`UPDATE ... SET
status = 'retired'`) every live link the list leaves out, then one
`INSERT ... ON CONFLICT (item_id, modifier_group_id) WHERE item_variant_id IS
NULL DO UPDATE` writes the list: a kept or retired link keeps its UUID and
takes the rules, the place and `active`; only a new link gets a UUID; then
it inserts the requested defaults. No statement deletes an offer row. The
command judges a retired group like a new one (it must be active and the
change is gated), since the item read leaves it out. The audit value is
`offerId:groupId:min:max:status`, followed by the sorted defaults as before;
the after list ends with the offers the change retired. `ItemModifierGroup`
carries the offer id and status inside the application only: the item
answer is unchanged. **Not built in K9b:** the `suspended` flag of §4.3 on
the item route, which changes the request and the item answer and so the
generated OpenAPI document (`docs/generated/openapi.json`); suspension is
still written only in SQL. Proofs, with real referencing rows: a Pricing
`modifier_option_price_entries` row survives an edit and a retirement of its
offer; a Sales-shaped row (a test table whose foreign key copies the three
real ones, each checked to be `NO ACTION`) survives a retirement, because a
Sales or Commerce row needs an order or a cart with a dozen unrelated
parents; the rollback test makes the audit record fail after the write and
finds the offers, their ids, the item tag and the trail unchanged. Rows in
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md), C13. Rollout: no
migration; the previous API (K9a) already ignores retired rows, so it reads
correctly what K9b writes, but it still deletes and re-inserts the offers it
edits: until every instance runs K9b an edit served by an old instance gives
new ids, which is why Sales, Commerce and Pricing reference offers only after
K9b is deployed and K9c revokes `DELETE` after that.

**As built (K9c, migration `0091_catalog_offers_are_never_deleted`, baseline
revision 101).** The contract step after K9b: the migration revokes `DELETE`
on `catalog.item_modifier_groups` from `ninaku_runtime`, and
`database/access/runtime_grants.sql` no longer grants it, so a fresh install
and an upgraded database end with the same privileges (`SELECT, INSERT,
UPDATE`). The runtime keeps `DELETE` on `item_categories` and on
`item_modifier_group_defaults` (the offer command deletes the defaults of the
offers it retires), and holds no `DELETE` on `items`, `item_variants` or
`modifier_groups`, so no cascade reaches an offer either. A runtime `DELETE`
of an offer, referenced or not, `active`, `inactive` or `retired`, fails with
`42501`; retiring, reviving and editing in place are `UPDATE`s and keep
working. Only the administrator can still delete an offer (the contracts that
prove the version trigger and the cascade to the defaults on delete now run
as the administrator). Proofs:
`database/tests/contracts/catalog-offers-never-deleted-contracts.ts`
(`NINAKU_CATALOG_OFFERS_NEVER_DELETED_OK`). Rollout: the migrator runs first;
revoking a privilege is irreversible only in that an image older than K9b,
which deletes and re-inserts the offers of an item, would now fail that edit
with `42501` (the whole edit rolls back, nothing is lost); the owner confirmed
that staging and production run K9b, so no running instance deletes an offer.

**Audit and events in the same transaction.** One
`catalog.item.modifier_groups_set` record per real change (as today), its
value listing each offer as `offerId:groupId:min:max:status`; since K12a
(built) the same transaction writes `catalog.offer.changed` events with the
offer id and its new status, and a tombstone `retiredAt` when the offer is
retired, into `events.outbox_records` through `events.append_tenant_event`, so
a rollback leaves no row, record or event.

**Acceptance criteria (architect §3).**

| Case | Required result | Planned test |
|---|---|---|
| Edit the maximum of O1 | O1 keeps its UUID | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `keeps the offer id when its maximum changes` |
| Reorder the offers | no existing link changes UUID | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `keeps every offer id when the offers are reordered` |
| Add O2 | only O2 gets a new UUID | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `gives a new id only to a newly offered group` |
| O1 has a specific price (a Pricing-shaped referencing row) | the edit succeeds and the reference stays valid | `src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts` › `edits and retires an offer that a pricing row references without breaking the reference` |
| O1 has historical sales (a Sales-shaped referencing row) | history stays readable; retiring O1 succeeds | `src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts` › `retires an offer that a sale row references and keeps the row readable` |
| Retire a required O1 | the item stays sellable; never read as "required but unavailable" | `test/e2e/catalog-till-parity.e2e-spec.ts` › `keeps an item sellable after its required group is retired, but not after it is suspended` |
| Re-offer a retired group | the same UUID returns to `active` | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `revives the same offer id when a retired group is offered again` |
| Rollback of the edit | no link, audit record or event partially written | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `leaves the offers, their ids, the audit trail, the announced changes and the version untouched when the record fails after the write` (built in K9b against PostgreSQL; since K12a it also proves that no event, outbox row or version move survives the rollback, and that the successful command announced its changes) |
| Runtime deletes an offer (after K9c) | `42501` | `database/tests/contracts/catalog-offers-never-deleted-contracts.ts` › `NINAKU_CATALOG_OFFERS_NEVER_DELETED_OK` (built in K9c) |

### 4.2 Effective offers (H3, H4)

```text
effective(V, G), for variant V of item I and group G offered on I or on V:
  if a variant row (V, G) exists with status 'active'   -> that row      (source 'variant')
  if a variant row (V, G) exists with status 'inactive' -> not offered    (opt-out)
  otherwise (no variant row, or a retired one):
    item row (I, G) 'active'   -> that row                                (source 'item')
    item row (I, G) 'inactive' -> suspended: min > 0 makes V not configurable; else omitted
    item row (I, G) 'retired' or missing -> not offered
order: sort_order of the effective row, then source 'variant' first, then id
```

| Case (architect H4) | Result |
|---|---|
| Active override | replaces the item offer for that group |
| Missing override | inherits |
| Inactive override | **explicit opt-out** (OV9) |
| Required group with no available option (or fewer units than the minimum, OV11) | **that variant** is not sellable; the other variants of the item stay sellable |
| The till (C17), the search (C19), the scan (C18), `resolve` and `admit` | the same fragment, proven by one parity test |

Example (architect): wings item, group Sauces, item rule max 2; variant ×6
inherits (2); variant ×25 has an active override max 6; variant ×3 has an
inactive override (no sauces).

Sellability becomes **per variant**: an item is listed when at least one of
its active variants is configurable; a variant that is not configurable is
left out of `variants[]` in C17 and C19, refused by C18 like an unknown code,
and refused by `admit` with `required_modifier_unsatisfiable`.

**As built (K9f, no migration).** The rule is one SQL fragment,
`effectiveOffer(offer, variant)` in
`infrastructure/reads/sellable-rule.queries.ts`: an offer row of the
variant's item that is not `retired` and either belongs to the variant and is
`active`, or is item-level with no live (`active` or `inactive`) row of the
variant for the same group; the override probe uses
`item_modifier_groups_variant_scope_uq`. `variantConfigurable(variant)`
judges the required groups among those rows (a suspended item row, an
inactive group or too few available options makes the variant not
configurable), `configurableVariantExists(item)` lists an item while one
active variant is configurable, and `sellableVariant(scope, variant)` is the
verdict of the scan, `resolve` and `admit`. The till list and the search
(C17, C19) keep only configurable variants in `variants[]` and count an item
in `omittedItems` only when none is; the scan (C18) answers `null` for an
unconfigurable variant; `resolve` and `admit` answer the effective offers of
the requested variant, `source: 'variant'` and the override's id when it
applies, in the order of this section, and the options an opted-out group
holds answer `option_not_offered`. Proofs:
`test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` runs one table of
cases (inherit, active override, opt-out, unsatisfiable required override,
unsatisfiable inherited group, opt-out of it, retired override) through the
scan, the till, the search, `resolve` and `admit` and finds the same verdict;
the planned parity titles live there instead of
`catalog-till-parity.e2e-spec.ts`, which is already at the file-size limit.
The Sales rows built from an admitted active override pass the real Sales
trigger and group check and fail with the item offer id. **X13 is closed
(K9g-0, migration `0090`, baseline revision 100):**
`sales.validate_line_modifier_insert` and
`sales.assert_order_line_modifier_consistency` choose the variant row among
`active` and `inactive` first and only then drop the group when it is
`inactive`, ignoring `retired` rows, so a variant that opted out of a
required item-level group is admitted by Catalog with no selection and
stored by Sales with no selection, and a selection through the item offer on
that variant is refused with `23514` (`Modifier Group is not offered on
selected Order component Variant`). Proofs: the opt-out parity case of the
e2e file above and `database/tests/contracts/sales-variant-offer-contracts.ts`
(active override, opt-out, retired override, inherited, suspended item offer
and item-only offer against the real Sales functions). Variant rows are
written by the K9g route (§4.3).
`EXPLAIN (ANALYZE, BUFFERS)` on a local world of 20,000 items × 2 variants
(two item offers each, 30 % with an active override, 10 % with an opt-out),
in a rolled-back transaction as `ninaku_runtime`: the till read (2,000 items)
went from 195 ms and 485,562 buffers to 230 ms and 607,577; `resolve` of 100
variants from 13 ms and 7,763 buffers to 11 ms and 8,037. One machine, not a
capacity claim; `perf:http` must be re-run for C17 and C19.

**As built (F1, satisfiability by units, no migration).** The architect's
functional review (F1) asked to bring forward the part of K9h that judges a
required group by units; this fix delivers it. The effective maximum of an
option was its own `maxQuantity` until per-offer limits could lower it (OV16);
K14b did, and the same sum now takes the lower of the option maximum and the
limit of that offer (see "K14b as built"). What a group can give an offer is the
sum of the `maxQuantity` of its available options under the one
availability rule (active option; for a `catalog_variant` option an
active target variant of an active item, the structural `activeOption` of the
writes; the reads add, since K16b, that the target is listed and shown,
`availableOption`), the arithmetic of `admit` (K9d)
and of `sales.assert_order_line_modifier_consistency`. Three places used to
count options and now sum units, so they agree: the offer command (C13),
whose `SHARE_LOCK_GROUPS` now hands `judgeOffer` every available option with
its `maxQuantity` (before: the count of active options, which ignored the
target of an extra) and whose domain rule refuses a minimum above that sum
with `selection_rule_invalid`; `variantConfigurable`, the one fragment of
the till, the search, the scan, `resolve` and `admit`, whose required-group
test compares the minimum with `coalesce(sum(max_quantity), 0)` of the
available options instead of `count(*)`. The offer's own maximum caps
nothing here: the minimum is never above it, and a maximum above the
reachable units stays accepted as before (Square's `allow_quantities` rule,
OV5; `admit` judges each option on its own maximum). Option writes still do
not judge offers again (K5 design note): deactivating or archiving an
option, pausing an extra's target or lowering a `maxQuantity` below an
offered minimum succeeds, and the item leaves every reader with
`required_modifier_unsatisfiable` until the units come back, while a changed
rule with that minimum is refused by the command, so both ends use the same
sum. The public `activeOptionCount` of the group reads (C12) is unchanged:
it is a count shown to the back office, not a capacity. Proofs:
`test/e2e/catalog-unit-capacity.e2e-spec.ts` (one Cheddar of maximum 2
offered with minimum and maximum 2 is accepted, sold by the scan, the till,
the search and `resolve`, admitted × 2 and stored by the real Sales checks,
refused × 1 and × 3; Gouda deactivated then archived keeps the item on
sale; Cheddar lowered to 1 hides it everywhere and the minimum of two is
refused again; an extra worth two units behind a paused item counts
nothing), `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` ›
`accepts a minimum that the available options can reach through their
quantities`, and the statement tests of `SHARE_LOCK_GROUPS` and
`variantConfigurable`; reverting any of the three places to a count fails a
named test. Rollout: no migration, no response shape changed; an offer
stored in SQL whose minimum only units can reach becomes sellable, and an
offer the command accepted before can only gain capacity, except one that
counted an extra behind a paused target, which the readers already treated
as unavailable.

### 4.3 Per-variant offers: writing them

`PUT /catalog/items/{itemId}/variants/{variantId}/modifier-groups` (K9g;
since API v7 PR 5 the `variantOverrides` of the item route, see below)
takes the same body as the item route plus a per-group `optedOut: boolean`
(writes an `inactive` variant row). The item route gains a per-group
`suspended: boolean` (writes an `inactive` item row). Both follow OV1–OV3.
Archiving a variant leaves its overrides as they are (the variant is not
sellable); archiving the item retires nothing (history).

**As built (K9g, no migration).** The item route and the new variant route
share one command (`OfferModifierGroupsUseCase`, with an optional
`variantId`) and one writer (`infrastructure/modifiers/item-offer-writes.ts`)
whose three statements are generated per scope: the item scope keeps the
K9b statements (`item_variant_id IS NULL`, `ON CONFLICT (item_id,
modifier_group_id) WHERE item_variant_id IS NULL`), the variant scope binds
the variant and upserts by `item_modifier_groups_variant_scope_uq`, so a
kept, retired or opted-out link keeps its UUID in both (OV1–OV3); the upsert
now writes the status of each row (`active`, or `inactive` for
`suspended`/`optedOut`). Domain (`domain/modifiers/modifier-offer.ts`): an offer
carries `inactive`; an opt-out is normalised to `0`, `1` and no defaults
and is never judged beyond the group existing (an inactive or archived group
may be opted out of); a suspension of a kept offer whose rule is unchanged
is not judged again, but every transition to `active` (a new offer, a resume
from a suspension, an opt-in from an opt-out) judges the group status
(`422 · catalog.modifier_group_not_active`) and the rule again, so no
sequence of requests makes an inactive or archived group an active offer
(review fix; only an offer that stays `active` with its rule keeps a group
deactivated since). **Plan:** `isUngatedOffer` replaces
`isShrinkingOffer`: a list is ungated when every group sent is unchanged or
a free restriction and it drops a group or restricts one; on a variant an
opt-out is always free, on the item a suspension only with the stored rule;
an override, an opt-in, a resume and a pure reorder stay gated. The variant
must belong to the item (`404 · catalog.not_found` otherwise, the same
answer as unknown or foreign), an archived variant answers
`422 · catalog.variant_archived` (`refuseAnArchivedVariant`, as the variant
and barcode commands), an inactive one may be configured; `If-Match` and
the answer use the item ETag, a hash of the item read, which now holds the
variant offers. **Audit:** no new action type (the action types are rows of
`audit.action_types`, a migration): the variant route records
`catalog.item.modifier_groups_set` on the item with `catalog.item.variant`
(the variant id, before and after) and
`catalog.item.variant.modifier_groups` (`offerId:groupId:min:max:status`
lists). **Reads:** the item read (C3) reads every live row in its one offer
statement and answers `suspended` on each item group and
`variants[].modifierGroupOverrides` (`optedOut` plus the full group shape);
the where-used read answers `variantId` (null for the item offer), lists
active overrides but never an opt-out, and its keyset tie-break is now the
offer id instead of the item id, so a cursor issued before the deploy may
skip or repeat one row of a page once. **Till (§5.2):** C17 and C19 answer
`variants[].modifierGroupOverrides` only on a variant with an opt-out or an
active override whose group has an available option (its rows are chosen by
`liveVariantOffer`, the same fragment `effectiveOffer` uses to let a variant
row replace the item offer, so the till and `admit` cannot disagree on which
rows count), and no key otherwise
(zero bytes for an item without overrides, proven by the till e2e); the
top-level `modifierGroups` include groups offered only by an active variant
row; the ETag is unchanged (the catalog version moves on every offer
write); the statement count is unchanged (one correlated subquery per
listed variant, probing the variant scope index). Proofs: the K9g rows of
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) (C3, C12, C13, C17,
C19). **Sales parity of an opt-out written by the route** (X13
closed by K9g-0, migration `0090`):
`test/e2e/catalog-variant-offer-sales-parity.e2e-spec.ts` writes the
opt-out through the variant route, then `admit` answers no offer for that
variant and the real Sales trigger and group check store its line with no
selection and refuse a selection through the item offer, while the variant
that inherits keeps the item minimum in both. **Still open:** K9c (revoke
`DELETE` on offers) and a `perf:http` re-measure of C17 and C19. The new route is classified administrative and
unmeasured in `tooling/performance/http/route-classification-catalog.ts`. Rollout: no migration; the previous API (K9f) reads variant
rows and suspended item rows correctly, but its item route sets every row
it keeps back to `active` (it knows no `suspended`), so a suspension made
by a K9g instance can be undone by an edit served by a K9f instance until
every instance runs K9g; and the two versions hash a different item read,
so an `If-Match` taken from one version answers `412` on the other during
the rollout (re-read and retry).

### 4.4 Quantity semantics (H2)

| Concept (architect §4) | Example | Treatment here |
|---|---|---|
| Base quantity of a variant | 0.25 kg | Existing decimal sales policy (`0080_catalog.sql:153-187`); judged by `admit` (OV22) |
| Count of an option | 2 shots, 2 extras | `quantity`, integer 1–999 (OV5) |
| Measurable amount of an option | 0.25 kg extra | *Proposed* (OV6): an option flag `measured_unit_code` plus `measured_min`, `measured_step`, `measured_max`; the selection carries `amount` (decimal string) instead of `quantity`; Sales needs `amount` and `amount_unit_code` columns; Production multiplies its `add` effect by it (`0180_production.sql:6285-6289`) |
| Validity of a service | a one-month membership | The Gym vertical's rule, not a quantity |

**Rules:** group min/max on the sum of units; quantity ≤ the option's
effective maximum; one option at most once per line; a preselection is not
a selection (OV7). *Proposed* extension: `maxDistinctOptions` on the offer
("up to 2 different sauces, up to 4 portions in all"), judged beside the
unit rule, when an owner names a menu that needs it.

**Acceptance criteria (architect §4)**, all in K9d:

| Case | Result |
|---|---|
| Cheese × 2 | expressed once with `quantity: 2`, admitted when min ≤ 2 ≤ max |
| Same min/max in contract and SQL | proven by the parity database contract (§9.4) |
| Quantity above `maxQuantity` | `option_quantity_above_maximum` |
| Repeated option, quantity 0, negative, non-integer, malformed id | `CatalogSellableRequestInvalidError` (`duplicate`, `invalid_quantity`) before any SQL |
| Preselection | never added by `admit`; the command sends what was kept |
| Measurable amounts | not accepted in v1 (`invalid_quantity` for a decimal) |

**As built (K9d, no migration, baseline revision 95).** `AdmissionLine` is
`{ variantId, quantity, selections: [{ optionId, quantity }] }` and each
admitted line answers `{ snapshot, quantity, selections }`: the line quantity
in its shortest decimal form and the selections as sent (ids lowercase), so a
preselection is never added (OV7). Before any lock: a line quantity that is
not a positive decimal of at most 14 integer digits and 6 decimals, or a
selection quantity that is not an integer from 1 to 999, throws
`invalid_quantity`; an option twice in a line, in any case, `duplicate`;
more than 100 selections `too_many_options`. Per line, after the sellable
verdict: `quantity_out_of_policy` when the quantity is below the variant
minimum, above its maximum or `(quantity − minimum) mod step ≠ 0`, the rule
of the Sales order-line trigger (`0110_sales.sql:3515-3527`), computed
exactly on integers scaled by 10⁶ (no `decimal.js` wrapper exists in
`foundation/` yet); then per selection, in request order,
`option_not_offered`, `option_unavailable` or
`option_quantity_above_maximum` (the effective maximum of the option in its
offer: its own `maxQuantity`, lowered by the limit of that offer since K14b);
then per offer `selection_below_minimum` or
`selection_above_maximum` on the sum of units. A malformed option id still
answers `option_not_offered` without reaching SQL, as in K8: the only new
request problem the design names is `invalid_quantity`. Parity proof:
`test/e2e/catalog-sellable-quantities.e2e-spec.ts` builds, in a rolled back
admin transaction, a Sales order and line (only those two parents inserted
with `session_replication_role = replica`, so their foreign keys and
triggers are skipped), inserts the modifier rows a sale would store through
the real `sales.validate_line_modifier_insert` trigger and calls the real
`sales.assert_order_line_modifier_consistency`: for ten selections Sales
accepts exactly what `admit` admits, and counting distinct options instead
of units breaks the test. The line policy is not run through the Sales
order-line trigger (skipped with the parents); the domain test proves the
copied rule. **Not built in K9d** (K9e): `offers[]` with the effective offer
identity, `lineIndex`, removed and reduced defaults, the
`database/tests` parity contract and the remaining `MODULE_CONTRACTS.md`
sentences of §16. Rows in [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md),
C20. Rollout: no migration and no consumer yet besides the contract's test
module and the `perf:admission` sale generator, so no compatibility window.

### 4.5 Option kinds

| Kind | Meaning | Physical effect owner | Price owner |
|---|---|---|---|
| `instruction` | "No onion", "Medium rare", "Gift wrap" | `production.recipe_modifier_effects` (`0180_production.sql:1237-1430`), instruction options only (`:4312-4317`) | Pricing; 0 when no row (`0090_pricing.sql:427-428`) |
| `catalog_variant` | "Extra cheese", "Warranty 2y" | the target variant's stock link (`0170_inventory.sql:410-460`) or recipe | Pricing; the target's price when no row (`0090_pricing.sql:429`) |
| `text` *(K14c)* | engraving, cake message | none | Pricing, like an instruction |

Text: `textMaxLength` 1–500, required for `text` only; a chosen text option
carries a non-blank value without NUL or control characters; optional text
\= a group with min 0; quantity exactly 1; at most 10 text selections per
line. Built in K14c (the option) and K14d (the selection): "K14c as built",
"K14d as built".

### 4.6 Prefixes

| Prefix | Production effect it names (`0180_production.sql:1232-1235`) | Rule |
|---|---|---|
| `add` | `add` | any quantity |
| `extra` | `add` (quantity multiplies, `:6285-6289`) or `scale` | `scale` needs quantity 1 |
| `light` | `scale` < 1 | `maxQuantity = 1` |
| `no` | `remove` | `maxQuantity = 1`, `instruction` only |
| `on_the_side` | none (kitchen hint) | `maxQuantity = 1`, `instruction` only |
| `substitute` | `replace` | `maxQuantity = 1`, `instruction` only |

Production refuses remove/replace/scale unless the sale quantity is 1
(`0180_production.sql:6300-6304`).

### 4.7 Defaults and removal of a default

`admit` answers per effective offer `removedDefaults` (available defaults not
selected) and `reducedDefaults` (selected with less quantity). Sales cannot
persist a removed default today (`quantity BETWEEN 1 AND 999`,
`0110_sales.sql:1271-1274`; X4). Until it can, a removal the kitchen must
see is modelled as a "No X" option. Default quantities (`quantity` on
`item_modifier_group_defaults`) are built in K14a (see "K14a as built"): a
default counts its quantity against the `maxSelections` of the offer and
`reducedDefaults` names a default chosen with less than that quantity. Since
K14b a default cannot be an option limited to 0 in its offer and its quantity
cannot exceed the effective maximum of the option there.

### 4.8 Portions (deferred)

Needs a portion table per offer, a `portion_code` on the Sales and Commerce
modifier rows, a uniqueness of (line, offer, option, portion) instead of
`order_modifier_options_base_selection_uq` (`0110_sales.sql:1309-1315`), and
a Pricing scale factor. Pattern meanwhile: two groups ("Toppings – 1st
half", "– 2nd half").

### 4.9 Nested options (deferred)

Shape: `catalog.modifier_option_offers (id, organization_id, catalog_id,
parent_modifier_option_id, modifier_group_id, min_selections,
max_selections, sort_order, status)` with the same retire/suspend vocabulary,
depth ≤ 2; the contract's selection gains child `selections[]`; Sales and
Commerce need a `parent_modifier_selection_id`. Pattern meanwhile: bundles
(C22).

### 4.10 Availability, allergens, routing, receipt, translations, tax, charge basis

| Topic | Owner and mechanism |
|---|---|
| Option sold out at an outlet | `catalog_variant`: Operations on the target variant (`0140_operations.sql:4598-4622`); `instruction`: Operations twin table (Q2) |
| Option only at some business units | per-item or per-variant offers; per-option assortment *Proposed* |
| Option per daypart | menus (OD7) |
| Allergens of options | inherited from the target item for extras; instruction options wait for `catalog_food` (R12) |
| Kitchen routing | Kitchen (`0150_kitchen.sql:607-637`, `:725-735`); Catalog adds `printsOnKitchenTicket` (K14c) |
| Receipt | `printsOnReceipt` (K14c); Printing lays out |
| Translations | C23 (`0080_catalog.sql:1413`, `:1576`) |
| Tax | per variant (`0260_tax.sql:646-663`); no tax hint in Catalog |
| Charge basis (once, per night, per person, percentage) | Pricing; not expressible today (X6) |

***

## 5. Contract shapes (H2, H3)

### 5.1 Admission (K9d, K9e)

```text
admit(context, {
  businessUnitId,
  lines: [{                               // ≤ 100
    variantId,
    quantity,                             // decimal string, sales policy (OV22)
    selections: [{ optionId, quantity }]  // ≤ 100, integer 1..999, each option once
  }]
})
→ {
  catalogVersion,
  lines: [{
    lineIndex, snapshot, quantity,
    offers: [{                            // the effective offers of the variant (§4.2)
      itemModifierGroupId,                // stable (OV1); what Sales, Commerce, Pricing reference
      modifierGroupId,
      source,                             // 'item' | 'variant'
      minSelections, maxSelections,
      groupNameSnapshot,
      selections: [{ modifierOptionId, optionNameSnapshot, kind, quantity,
                     targetVariantId, targetSalesUnitCode }],
      removedDefaults: [{ modifierOptionId, optionNameSnapshot, defaultQuantity }],
      reducedDefaults: [{ modifierOptionId, optionNameSnapshot, defaultQuantity, quantity }]
    }]
  }]
}
```

`resolve` answers the same `offers[]` shape (without selections) inside each
snapshot, so Pricing configures against the same identities.

**Refusals, in order per line:** today's sellable verdict
(`domain/sellable/sellability.ts:44-77`); `quantity_out_of_policy` (new);
`option_not_offered`; `option_unavailable`; `option_quantity_above_maximum`
(new); `selection_below_minimum` / `selection_above_maximum` on the sum of
units. Request problems: `empty`, `too_many`, `too_many_options`, `duplicate`
(an option twice in a line), `invalid_quantity` (new). Whole or nothing, as
today; the same two shared locks.

**Sales mapping (`0110_sales.sql:1177-1307`).**

| Sales column | From |
|---|---|
| `item_modifier_group_id` | `offers[].itemModifierGroupId` |
| `modifier_option_id` | `selections[].modifierOptionId` |
| `group_name_snapshot`, `option_name_snapshot` | the snapshots |
| `quantity` | `selections[].quantity` |
| `quantity_unit_code_snapshot` | `targetSalesUnitCode` (required for `catalog_variant`, forbidden for `instruction`, `:4103-4118`) |
| charges | Pricing |

**As built (K9e, no migration, baseline revision 96).** The snapshot of
`resolve` carries `offers[]` instead of `modifierGroups[]`: each offer is
`{ itemModifierGroupId, modifierGroupId, source, name, minSelections,
maxSelections, defaultOptionIds, options }`, and each option adds
`targetSalesUnitCode`, the sales unit of its target variant (`null` for an
instruction), read in the same statement. Each admitted line is `{ lineIndex,
snapshot, quantity, offers }`; every effective offer of the variant is
answered, with or without selections, as the shape above
(`groupNameSnapshot`, `selections`, `removedDefaults`, `reducedDefaults`),
the selections in request order. The flat `selections` of K9d is gone: the
offer holds them. The derivation is one domain function
(`domain/sellable/admitted-offer.ts`): a removed default is an available
preselection the line left out, a reduced one a preselection chosen with
less than its default quantity; every default counted 1 until K14a gave
defaults a quantity (a default without an entry in `defaultQuantities` still
does), and a default no longer available is never named. **The effective offer is the
item-level offer:** the reads still take only `item_variant_id IS NULL`
rows, so `source` is always `item`; the override, opt-out and suspension
rule of §4.2 is K9f, and until then a variant-level row (only writable in
SQL) is invisible to Catalog while Sales already prefers an active one
(`0110_sales.sql:4120-4134`), and Sales still lets an inactive variant row
fall back to the item offer (X13). Parity proof: the K9d e2e harness, not the
planned `database/tests` contract (that path is outside this slice's
surfaces):
`test/e2e/catalog-sellable-offers.e2e-spec.ts` builds Sales modifier rows
from the admitted answer alone (offer id, group and option names, quantity,
`quantity_unit_code_snapshot` from `targetSalesUnitCode`) for an instruction
and a `catalog_variant` extra, and the real
`sales.validate_line_modifier_insert` trigger and
`sales.assert_order_line_modifier_consistency` store them; answering the
group id instead of the offer id, or no unit for the extra, breaks it.
`MODULE_CONTRACTS.md` takes the sentences of §16 (the effective-offer
sentence qualified with "until K9f, `source` is always `item`") and the X12
correction. Rows in [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md), C20.
Rollout: no migration; the contract has no consumer yet besides its test
module and the `perf:admission` generator, so renaming `modifierGroups` to
`offers` and moving the selections into the offers needs no compatibility
window. Sales and Pricing may call `admit` from now on (§11).

### 5.2 Till

C17 keeps item-level `modifierGroups` per item and adds, only on variants
that have overrides, `variants[].modifierGroupOverrides: [{ modifierGroupId,
optedOut, minSelections, maxSelections, defaultOptionIds }]` (a delta). The
offer id is not sent to the till; the till sends option ids, `admit`
resolves the offer.

**Not built in K9f, built in K9g (§4.3, *As built (K9g)*):**
`modifierGroupOverrides` on each listed variant that overrides or opts out,
absent otherwise, so the till shows on each variant the rule `admit`
judges.

### 5.3 What each neighbour receives

| Neighbour | Receives through the contract | Never receives |
|---|---|---|
| Pricing | `itemModifierGroupId`, `source`, `modifierOptionId`, `kind`, `targetVariantId`, `quantity` | a price |
| Discounts | `modifierGroupId`, `modifierOptionId`, `variantId`, `itemId` (targets at `0090_pricing.sql:1638-1720`) | rules |
| Inventory | `targetVariantId` × `quantity` for extras | stock |
| Production | `modifierOptionId` × `quantity` for instructions | recipes |
| Tax | line variant, `targetVariantId` | classes |
| Commerce | the same admission shape | — |

***

## 6. SQL ownership (H7)

### 6.1 Accesses to replace

| Catalog file | Owner table read | Use |
|---|---|---|
| `infrastructure/items/item.queries.ts:84` | `organization.business_units` | unit exists (filters) |
| `infrastructure/sellable/sellable.queries.ts:69`, `:109` | `organization.business_units` | unit operable; admission share lock |
| `infrastructure/barcodes/barcode-lookup.queries.ts:18-22`, `:66` | `organization.effective_business_unit`, `outlets`, `sites` | outlet → unit on the site date |
| `infrastructure/assortment/assortment.queries.ts:3`, `:55` | `organization.business_units` | unit state |
| `infrastructure/reads/item-list.queries.ts:64` | `organization.business_units` | listed filter |
| `infrastructure/reads/catalog-read.queries.ts:49-50` | `organization.business_units`, `platform.business_unit_verticals` | workspace units and verticals |
| `infrastructure/till/till-read.queries.ts:43`, `:187` | `organization.structure_versions` | till ETag |
| Organization: `business-unit-vertical.repository.ts:22-45` (gone since K10c; Platform owns the write in `platform-business-unit-verticals.repository.ts`) | writes `platform.business_unit_verticals` | vertical activation |

Also a catalog trigger on `organization.organizations` creates each
organization's version row (`0080_catalog.sql:4174`).

### 6.2 Recommendation (OV12)

**Default: in-process read contracts sharing the opaque `TransactionContext`**
(`src/foundation/database/transaction-context.ts:13`), exactly like
`TenantAuditContract`. New, in `src/modules/organization/contracts/` (the
directory does not exist yet) and `src/modules/platform/contracts/`:

```ts
export abstract class OrganizationContextContract {
  abstract businessUnitStates(
    context: TransactionContext,
    input: { readonly businessUnitIds: readonly string[] },
  ): Promise<readonly BusinessUnitState[]>;
  abstract outletOperatingContext(
    context: TransactionContext,
    input: { readonly outletId: string },
  ): Promise<OutletOperatingContext | null>;
  abstract shareBusinessUnit(
    context: TransactionContext,
    input: { readonly businessUnitId: string },
  ): Promise<boolean>;
}
export interface BusinessUnitState {
  readonly businessUnitId: string;
  readonly name: string;
  readonly status: string;
}
export interface OutletOperatingContext {
  readonly outletId: string;
  readonly outletStatus: string;
  readonly siteLocalDate: string;
  readonly businessUnitId: string | null;
  readonly structureVersion: number;
}

export abstract class BusinessUnitVerticalsContract {
  abstract activeVerticals(
    context: TransactionContext,
    input: { readonly businessUnitIds: readonly string[] },
  ): Promise<readonly BusinessUnitVertical[]>;
  abstract activate(
    context: TransactionContext,
    input: BusinessUnitVertical,
  ): Promise<BusinessUnitVerticalChange>;
  abstract deactivate(
    context: TransactionContext,
    input: BusinessUnitVertical,
  ): Promise<BusinessUnitVerticalChange>;
}
export interface BusinessUnitVertical {
  readonly businessUnitId: string;
  readonly verticalCode: string;
}
export interface BusinessUnitVerticalChange {
  readonly outcome: 'changed' | 'unchanged' | 'unavailable' | 'last_active_vertical';
  readonly previousStatus: 'active' | 'inactive' | null;
}
```

Catalog's workspace, item list, assortment and the back-office reads call
these (one extra statement each, within their budgets). Organization's
vertical command calls `BusinessUnitVerticalsContract.activate/deactivate`
in its own transaction; Platform owns the SQL.

**Normative exception, documented in `ARCHITECTURE.md` §7 and
`MODULE_CONTRACTS.md`:** the single-statement hot reads (C17, C18, C19,
`resolve`, `admit`) may call **only owner-published SQL read functions**,
never owner tables: `organization.outlet_operating_context(p_organization_id
uuid, p_outlet_id uuid) RETURNS TABLE (outlet_id uuid, outlet_status
varchar, site_local_date date, business_unit_id uuid, structure_version
bigint) STABLE` and `organization.business_unit_is_operable(p_organization_id
uuid, p_business_unit_id uuid) RETURNS boolean STABLE` (written by
Organization in its own migration, granted to `ninaku_runtime`, `SECURITY
INVOKER` so RLS applies, covered by its contract tests). The admission share
lock goes through `OrganizationContextContract.shareBusinessUnit` (one
statement, as today). Why the exception: these reads take one snapshot for
data, tree, groups and versions (`CATALOG_USE_CASES.md:1453`); splitting them
would break the ETag-to-body guarantee. `organization.effective_business_unit`
is already such a function; the exception names the rule it follows.

**As built (K10a, migration `0092`, baseline revision 102).** Organization
publishes `OrganizationContextContract` exactly as written above
(`src/modules/organization/contracts/organization-context.contract.ts`,
named input interfaces instead of inline object types), bound in
`OrganizationModule` and exported, with `OrganizationModule` re-exported from
`src/modules/organization/contracts/organization.module.ts`. Platform
publishes `BusinessUnitVerticalsContract` with `activeVerticals` only
(`src/modules/platform/contracts/business-unit-verticals.contract.ts`);
`activate` and `deactivate` arrive with K10c, which moves the writes. The two
SQL functions have the signatures above, `SECURITY INVOKER`, `search_path`
fixed to `pg_catalog, pg_temp`, `EXECUTE` for `ninaku_runtime` only;
`outletOperatingContext` reads `organization.outlet_operating_context`, so the
contract and the hot reads share one definition. Catalog does not call any of
them yet (K10b). Proofs:
`database/tests/contracts/organization-published-reads-contracts.ts`
(`NINAKU_ORGANIZATION_PUBLISHED_READS_OK`, parity with the inline fragment
Catalog runs today), the integration specs of both repositories and
`test/e2e/organization-published-read-contracts.e2e-spec.ts` (a module that
imports only the contracts). Open for K10b: the workspace read
(`catalog-read.queries.ts:46-56`) needs every active unit of the organization
and its verticals, but `businessUnitStates` and `activeVerticals` take unit
ids, so K10b needs a way to list the active units (a new
`OrganizationContextContract` method, or `businessUnitStates` without ids)
before it can drop `organization.business_units` there. K10b should call the
functions through scalar subqueries (`(SELECT organization.business_unit_is_operable(...))`)
so PostgreSQL evaluates them once per statement.

**As built (K10b, no migration).** Catalog names no table or function of
Organization or Platform outside the published functions. The hot reads (C17,
C18, C19, `resolve`, `admit`) call them inside their existing statements:
`assortmentModeAt` and `listedAt` (`infrastructure/reads/item-list.queries.ts`)
take the unit's operability as an SQL expression, and the hot reads pass
`operableUnit` (`infrastructure/reads/sellable-rule.queries.ts`,
`(SELECT organization.business_unit_is_operable(org, unit))`);
`OUTLET_STATE`, `OUTLET_EXISTS` and `MATCH_AT_OUTLET` read
`organization.outlet_operating_context`, and the till list joins it on its
outlet (`$5`) for the structure version of its ETag. The statement counts of
`test/e2e/statement-budget.e2e-spec.ts` are unchanged. `admit` shares the unit
row through `OrganizationContextContract.shareBusinessUnit` (one statement, as
before). The other reads go through the contracts: `CatalogBusinessUnits`
(`application/catalog-business-units.ts`, over `businessUnitStates`) answers
the read gates' unknown-unit check (one statement, as before), the unit status
in the assortment ETag (one more statement per read, one more per command)
and the operability behind the item list's `listed` filter, now a
`$N::boolean` parameter (one more statement, only with that filter). The
workspace warning `units_with_different_verticals` left `WORKSPACE_FACTS`:
`WorkspaceVerticals` (`application/reads/workspace-verticals.ts`) asks
`OrganizationContextContract.activeBusinessUnits` (new in K10b) and passes
their ids to `BusinessUnitVerticalsContract.activeVerticals`, and the use case
compares each unit's set of active verticals, only for an organization-wide
curator, the only reader who ever saw the warning. That is no extra
statement for any other reader, one for a curator in an organization with at
most one active unit (Platform is not asked) and two otherwise; the workspace
has no entry in the statement-budget e2e. A published SQL function would have
kept one statement, but the workspace is not one of the single-statement hot
reads §4.4 of `MODULE_CONTRACTS.md` names, so it uses the contracts.
`test/e2e/catalog-workspace-parity.e2e-spec.ts` compares the catalog, the
counts and the warnings with the statement that answered them before K10b
through six mixes of units and verticals. The lint `npm run lint:sql-ownership`
(`tooling/repository/check-sql-ownership.ts`, spec beside it) fails on any
production SQL of a module that names another module's objects, outside the
published functions, the tenant context functions and `reference`; the
references other modules still hold are frozen in
`tooling/repository/sql-ownership-baseline.json`, which only shrinks and holds
no Catalog entry.

**As built (K10c, no migration).** `BusinessUnitVerticalsContract` gained
`activate` and `deactivate` as sketched above, with one deviation: instead
of `{ changed, status }` they answer `{ outcome, previousStatus }`, where
`outcome` is `changed`, `unchanged`, `unavailable` or
`last_active_vertical` (a const object in the contract file). Organization
needs the previous status for its audit record (`null` for a first
activation, `inactive` for a reactivation), Platform cannot throw
Organization's failures, and Organization's use case does not catch, so
the refusals travel as outcomes and Organization throws the same
`organization.vertical_unavailable` (`422`) and
`organization.last_vertical_required` (`409`) as before. Platform binds the
contract to `BusinessUnitVerticalsFacade`
(`src/modules/platform/application/business-unit-verticals/`), which hands
reads to `ReadBusinessUnitVerticalsUseCase` and changes to
`ChangeBusinessUnitVerticalStatusUseCase`; the SQL moved to
`PlatformBusinessUnitVerticalsRepository` with three changes: every
statement names `(SELECT identity.current_organization_id())` (the insert
takes it as the organization, the reads, the update and the lock filter by
it, and the advisory lock key is the same `organization.verticals:<id>` the
business unit trigger takes), the lock of the active rows filters with
`organization.business_unit_is_operable(...)` instead of joining
`organization.business_units`, and a write the database refuses as an
unknown or unavailable vertical answers `false` instead of throwing. The
write stays in Organization's tenant transaction, with its audit record.
Statements per request: activation of a new pair or a reactivation 4 from
Platform (advisory lock, catalog, status, write) plus the same permission,
unit lock and audit as before; an activation already active 3; a deactivation 4
(status, advisory lock, `FOR UPDATE`, update); a deactivation already
inactive 1; the last vertical 3; all unchanged. An activation on an
inactive or archived unit is now refused before the catalog read (one
statement fewer). `src/modules/organization/infrastructure/business-units/business-unit-vertical.repository.ts`
is gone with its baseline entry, so Organization names no Platform table
outside provisioning. Proofs: the Platform use case and repository specs
(`platform-business-unit-vertical-locks.repository.integration-spec.ts` ›
`reads, changes and locks only the activations of the organization in context, even for a session RLS does not restrict`
runs as the administrator, whom RLS does not restrict), the Organization use
case specs and the `business-unit-verticals-*` e2e specs, unchanged.

**Activating a vertical is not a subscription bypass** (retracted from the
earlier analysis): "**No Organization command is capability-gated (D15).**"
(`docs/reference/ORGANIZATION_USE_CASES.md:2070`). Activating a vertical's
configuration and authorizing an operation of that vertical are separate
steps; H7 is only about who writes Platform's table.

***

## 7. Offline (H5): outline, not implementation

### 7.1 What exists and what is missing

| Exists | Where |
|---|---|
| Till reads per outlet, ETags, `catalogVersion`, keyset search, 2,000-item cap with `truncated` | `CATALOG_USE_CASES.md:1450-1454`; `sellable-read.ts:7` |
| Sync SQL: devices, context assignments, bootstrap sessions, commands, conflicts, change streams and batches with acknowledgements | `0370_sync.sql:185`, `:228`, `:272`, `:475`, `:498`, `:551`, `:727`, `:784-853` |
| Events SQL: domain events, publications, outbox | `0360_events.sql:127`, `:167`, `:202` |

| Missing (analyst and architect findings) | Evidence |
|---|---|
| Catalog publishes no domain events | no outbox write in `src/modules/catalog`. **Closed by K12a** (migration `0114`): the writes are SQL triggers of the catalog schema calling `events.append_tenant_event`, so `src/modules/catalog` still holds no outbox write by design (D40) |
| No tombstones: a retired or delisted thing produces no change a device can apply | no change feed; offers deleted until K9b. **Closed by K12a**: a retirement is a `retired` change with `retiredAt` |
| The version triggers cover 12 catalog tables only; menus, bundles, translations, `item_assets`, allergens, dietary tags and nutrition bump nothing | `0080_catalog.sql:4139-4272`, `:4480-4488`. **Closed for every table a device reads** (31 tables carry the triggers at K12a; the 17 translation, allergen, dietary tag and nutrition tables are read and written by no use case yet and get theirs with C23 and C24) |
| The till answer has no SKU and no barcodes, so a downloaded catalog cannot scan | `sellable-read.ts`. **Closed by K12b**: the bootstrap pages carry both |
| External ids exist for variants only | `integrations.external_mapping_catalog_variants` (`0350_integrations.sql:563`) |
| No Sync module, device grant or revocation, composed offline view, or replay | architect H5 |

### 7.2 Design outline

| Piece | Responsibility |
|---|---|
| Catalog change feed (K12a, **built**, "K12a as built") | Every statement that moves the catalog version writes, in its transaction, typed events (`catalog.item.changed`, `.variant.changed`, `.barcode.changed`, `.offer.changed`, `.listing.changed` and, because the till reads them, `.modifier.changed`, `.bundle.changed`, `.menu.changed` and `.category.changed`, each with `catalogVersion` and, for retirements, a tombstone with `retiredAt`) to `events.outbox_records`, by SQL triggers through the Events function `events.append_tenant_event`; every table a device reads carries version triggers |
| Bootstrap (K12b) | A device bootstrap reads at one `catalogVersion` (a `REPEATABLE READ` snapshot per page set, or pages pinned to a version and restarted only for the pages a later delta touches), unlimited items in pages, including SKU and the barcode index (barcode → variant) of what the outlet sells |
| Deltas | Applied in version order by Sync batches; idempotent by event id |
| Historical verification (K12c) | `CatalogSellableContract.verifyHistorical(context, { lines })`: checks only **immutable** structure (variant belongs to item, option belongs to group, offer belongs to item/variant and group, tenant), never current status |
| Sales replay | Sales' command with a stable id (`sync.commands`, `0370_sync.sql:551`), replayed through Sales' use case |

### 7.3 The 10:00 / 10:05 / 10:15 case (OV20, Q4)

A device sells offline at 10:00; the item is retired at 10:05; the device
syncs at 10:15. **Recommended policy:** accept the sale as a historical fact
when all hold: (1) the sale command carries the `catalogVersion` its local
snapshot was built from and the admitted identities (variant, offers,
options); (2) Sync shows the device had **not acknowledged** a batch
containing the retirement before the sale's device time
(`sync.change_batch_acknowledgements`, `0370_sync.sql:853`); (3) the device's
context assignment covered that outlet at that time (`0370_sync.sql:272`);
(4) `verifyHistorical` passes. Otherwise Sales records a conflict
(`sync.conflicts`, `0370_sync.sql:727`) for a manager to accept or void.
Device clock skew is bounded by the session's server time
(`sync.sync_sessions`, `0370_sync.sql:393`).

### 7.4 Acceptance checklist (architect H5)

| Criterion | Planned evidence |
|---|---|
| Bootstrap of more than 2,000 items with a consistent cut | e2e: 3,000 items, a write during the bootstrap, the device ends at one version with no duplicate or gap |
| Local barcode for a downloaded variant | e2e: scan resolved from the bootstrap index |
| Retiring an item removes it from the local offer | delta with a tombstone applied |
| Retrying a delta or a sale has no double effect | idempotency by event id and command id |
| Switching business keeps catalogs, prices and queues apart | device-scoped stores per business unit |
| A permission change rebuilds or invalidates the local scope by a defined policy | *Recommended*: revocation invalidates the device grant at the next session; a scope change forces a new bootstrap |
| Offline sales and printing survive a device restart | client persistence; out of the API |
| Stock, payments and fiscal consequences tested with their owners | Inventory, Payments, Fiscal |
| Stock oversell / reserve / offline allotment | Inventory's policy, owner question Q5 |

***

## 8. Structured variant attributes (H6)

**Status: built** (K13a migration `0109`, K13b no migration; see "K13 as built" at the end of this file for what changed from the design below).

The matrix (`domain/items/variant-matrix.ts:29-42`) today writes concatenated
codes and names (`m-azul`, `M / Azul`, `CATALOG_USE_CASES.md:1401`).

**Design (K13a SQL, K13b matrix and filters), all inside `catalog`:**

| Table | Columns | Rule |
|---|---|---|
| `variant_dimensions` | `id, organization_id, catalog_id, code, name, sort_order, status` | catalog-level ("Size", "Color"), so "every blue variant" spans items |
| `variant_dimension_values` | `id, organization_id, catalog_id, dimension_id, code, name, sort_order, status` | value identity survives a rename and carries translations later |
| `item_variant_dimensions` | `item_id, dimension_id, sort_order` | which dimensions define this item's variants; at most 3 |
| `item_variant_dimension_values` | `item_variant_id, dimension_id, dimension_value_id` | one value per (variant, dimension); the dimension must be one of the item's |

Uniqueness of a combination: a deferred constraint trigger refuses two
active variants of one item with the same set of values. The matrix command
creates the dimensions it is given, the values, the variants and the links
in one transaction; existing matrix variants are back-filled by parsing
nothing (their codes are opaque): an owner re-applies the matrix, which
links existing variants by code. Descriptive item attributes (material,
fit) are not variant-defining and wait for a named reader. No
`shirt_size`, `shoe_color` or vertical columns.

***

## 9. Concurrency, performance, security, tests

### 9.1 Concurrency and H8

* Offer commands: catalog version row `FOR UPDATE`, item `FOR UPDATE`,
  variant `FOR SHARE` (variant route), groups `FOR SHARE` in id order.
* Retire and revive are `UPDATE`s of a non-key column, compatible with the
  `FOR KEY SHARE` that Sales and Pricing FKs take.
* **H8:** `admit` shares the organization's catalog version row, so a
  catalog command of the organization (any business unit) waits for open
  admissions. The budget is ≤ 50 ms of transaction after `admit`
  (`CATALOG_USE_CASES.md:1470-1471`). **K11 measures the real Sales
  transaction** with `npm run perf:admission`; no admission may be held
  across remote payment, printing or fiscal calls. If the real hold exceeds
  the budget, the lock-free admission of `CATALOG_USE_CASES.md:1471` is
  designed before Sales ships.

### 9.2 Performance

| Effect | Estimate | Mitigation |
|---|---|---|
| K9d quantity judging | none measurable | — |
| K9f per-variant effective offers in C17 | ≈ +20 KB (+9 %) on the 300-item restaurant (214 KB) if 30 % of items override one group on two variants | delta overrides only; re-run `perf:http`; the known C17 p50 breach (`CATALOG_USE_CASES.md:1456`) grows with the body |
| Delta sync (K12) | needs stable ids (H1) | — |
| Per-variant satisfiability | evaluates effective offers per sellable variant | `EXPLAIN (ANALYZE, BUFFERS)` on the 20,000-item world (`CATALOG_USE_CASES.md:1241-1243`) |
| H7 contracts | +1 statement on back-office reads; 0 on hot reads (owner SQL functions) | statement-budget tests updated |

### 9.3 Security

| Risk | Control |
|---|---|
| Injection via text values (K14c) | bound parameters; ≤ 500 characters; NUL and control characters refused; NFC before length |
| Output injection on tickets, receipts, displays, LLM prompts | renderers encode for their medium and treat values as data |
| Resource exhaustion | ≤ 100 lines × 100 selections; ≤ 10 text selections per line; quantities 1–999 |
| Client-trusted prices | no price crosses the contract |
| Cross-tenant references | composite `(id, organization_id)` FKs; RLS with the scalar subquery; foreign ids answer like unknown ones |
| Offline replay abuse | §7.3 evidence; device grant revocation |

### 9.4 Case-matrix rows (planned titles)

**C13 (K9a–K9c):** the nine rows of §4.1.

**C20 (K9d–K9e):**

| Case | Expected | Planned test |
|---|---|---|
| 3 × one sauce in a group of max 3; 4 | admitted; `selection_above_maximum` | `src/modules/catalog/domain/sellable/sellability.spec.ts` › `judges every group on the sum of the chosen quantities, as the sales schema does` |
| Cheese × 2 with minimum 2 | admitted | `… › meets a minimum of two with one option chosen twice` |
| Quantity above the option maximum | `option_quantity_above_maximum` | `… › refuses a quantity above the option maximum, naming the option` |
| 0, 1000, 1.5, negative; an option twice | `invalid_quantity`, `duplicate` before any SQL | `src/modules/catalog/application/sellable/catalog-sellables.spec.ts` › `rejects a selection quantity outside 1 to 999 or not an integer, and an option repeated in a line, before any lock` |
| Line quantity off policy | `quantity_out_of_policy` | `src/modules/catalog/domain/sellable/sellability.spec.ts` › `refuses a line quantity below the minimum, above the maximum or off the step of the variant` |
| The answer carries the effective offer | `itemModifierGroupId`, `modifierGroupId`, `source`, min, max, names, kinds, target unit | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `admits quantities and answers the effective offer identity and everything a sale line stores` |
| Removed and reduced defaults | derived | `src/modules/catalog/domain/sellable/sellability.spec.ts` › `derives the removed and reduced defaults of each offer from the final selection` |
| Parity with the Sales trigger | rows built from the answer pass `validate_line_modifier_insert` and `assert_order_line_modifier_consistency` | `database/tests/contracts/catalog-sales-admission-parity-contracts.ts` › `NINAKU_CATALOG_SALES_ADMISSION_PARITY_OK` |

**C17/C18/C19/C20 (K9f–K9h):**

| Case | Expected | Planned test |
|---|---|---|
| Wings ×6 inherits max 2, ×25 overrides max 6, ×3 opts out | each variant its own rule | `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `resolves sauces per variant: inherited, overridden and opted out` |
| One variant's required override has no available option | that variant absent, the others sellable; scan, till, search and admit agree | `test/e2e/catalog-till-parity.e2e-spec.ts` › `gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override` |
| A suspended item-level required group | every variant not configurable | `… › hides every variant while a required item-level group is suspended and shows them again when it resumes` |
| Minimum 3 with one option of maximum 3 | satisfiable (OV11) | `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `accepts a minimum that the available options can reach through their quantities` |

**H7 (K10):** as built in K10b, a separate lint over every production file of
`src/modules/**`: `tooling/repository/check-sql-ownership.spec.ts` ›
`reports a table of another module named in a query, with its file and line`
and `fails with the file, the line and the foreign name it found`.

***

## 10. Slices (architect order)

Forecasts count authored added plus deleted lines, tests and docs included,
and the canonical mirror for migrations.

| # | Slice | Finding | Migration | Forecast | Depends on |
|---|---|---|---|---|---|
| 1 | **K9a** (built) — `retired` status (widened CHECK, live index); every offer reader ignores `retired`; no writer yet | H1 | `<next>_catalog_offer_retirement_status` (expand) | ≈ 250 | — |
| 2 | **K9b** (built) — stable offer identity: update in place, retire, revive, `suspended` flag, audit in one transaction; acceptance table of §4.1 with real referencing rows | H1 | none | ≈ 400 | K9a deployed |
| 3 | **K9c** (built) — revoke `DELETE` on offers (built) | H1 | `0091_catalog_offers_are_never_deleted` (contract step) | ≈ 110 | K9b deployed |
| 4 | **K9d** (built) — quantified selections: contract types, request validation, sum judging, option maximum, line policy | H2 | none | ≈ 380 | K9b |
| 5 | **K9e** (built) — effective offer identity in `resolve` and `admit`, removed/reduced defaults, Sales parity contract, `MODULE_CONTRACTS.md` sentences | H3 | none | ≈ 360 | K9d |
| 6 | **K9f** (built) — shared effective-offer fragment (override, opt-out, suspend) in C17/C18/C19/`resolve`/`admit`; per-variant sellability; parity e2e; `EXPLAIN`, `perf:http` | H4 | none | ≈ 420 (honest) | K9e |
| 7 | **K9g** (built) — variant route, C3 variant offers, where-used `variantId` | H4 | none | ≈ 400 | K9f |
| 8 | **K9h** (built) — satisfiability by units (OV11); delivered ahead by F1 (§4.2, *As built (F1)*) | H4 | none | ≈ 200 | K9f |
| 9 | **K10a** (built, migration `0092`) — Organization and Platform read contracts and SQL read functions | H7 | `<next>_organization_published_read_functions` (expand) | ≈ 350 | — |
| 10 | **K10b** (built) — Catalog queries switched to them; SQL-ownership lint | H7 | none | ≈ 400 | K10a |
| 11 | **K10c** (built) — `BusinessUnitVerticalsContract`; Organization calls it | H7 | none | ≈ 300 | K10a |
| 12 | **K11** (blocked: the Sales and Pricing modules do not exist) — minimal Sales/Pricing integration proof (a sale with options, a per-offer price, snapshots, rollback) and H8 measurement; owned with the Sales and Pricing designs | — / H8 | per those modules | per those designs | K9e, K9g |
| 13 | **K12a** (built, `0114_catalog_change_feed`, "K12a as built") — catalog change feed with tombstones; version triggers on every table a device reads | H5 | `0114_catalog_change_feed` | ≈ 400 | K9b |
| 14 | **K12b** (built, "K12b as built") — device bootstrap with a consistent cut, barcode index; a Catalog read API, since the Sync module does not exist | H5 | none | ≈ 400 | K12a |
| 15 | **K12c** — `verifyHistorical` and the replay policy | H5 | none | ≈ 300 | K12b, Sales |
| 16 | **K13a** (built) — structured variant dimensions (SQL; migration `0109`, "K13 as built") | H6 | `0109_catalog_variant_dimensions` | ≈ 350 | — |
| 17 | **K13b** (built) — matrix creates them, the item read shows them, `GET /catalog/variant-dimensions` and the `variantValue` filter ("K13 as built") | H6 | none | ≈ 400 | K13a |
| 18 | **K16** (built) — outlet hidden items: hide-only exceptions of one outlet over the assortment of the business unit that operates it (C30); `GET`, `PUT /catalog/outlets/{outletId}/hidden-items`; one shared SQL fragment in the till list, the search, the scan, `resolve` and `admit` | owner request | `0106_catalog_outlet_hidden_items` (expand) | ≈ 2,300 (tests and docs included) | M2a |
| 19 | **K14a** (built) / **K14b** (built) — default quantities, per-offer option limits; routes (`customer_visible` is dropped: it has no reader, no use case, and OV18 forbids channel facts in the catalog) | specialization | `0108_catalog_offer_defaults_quantities_and_overrides` / none | ≈ 330 / ≈ 400 | K9f |
| 20 | **K14c** (built) / **K14d** (built) — text kind, prefixes, print hints; the write route is the existing `PUT /catalog/modifier-groups/{modifierGroupId}` ("K14c as built"); K14d is the reads and the judging ("K14d as built") | specialization | `0110_catalog_option_kinds_and_presentation` / none | ≈ 230 / ≈ 380 | K9e; K14d after K14c deployed |
| 21 | **K15a** (built, `0111_catalog_master_data_hardening`, "K15a as built") / **K15c–K15d** — master data (§17): bounds and hardening, product brands, intrinsic identifiers; K15b was never defined (the GS1 check digit of §17.3 is already enforced in code); `item_kind` only when one of its named readers ships (OV21) | specialization | per §17.7 | ≈ 300 / 380 / 300 / 300 | — |
| 22 | Menus (C21; M1 structure built, §21, migration `0101`; outlet assignment M2a built, C29, migration `0105`; till read by menu M2b built; time windows S1 built, C31, migration `0107`), bundles (C22; B1 built, §19, migration `0100`; admission B2 built, §20, no migration; Pricing and Sales B3 next) | — | their own designs | — | verified needs |

Migration numbers: the next free number after `0083` (revision 93) when each
is written; the Assets stages also take numbers (`ASSETS_STAGE_SPECS.md:68`).

### 10.1 Coverage snapshot (baseline revision 117)

Measured against the code, not planned. The API v7 pull requests 1 to 7
(revisions 112 to 114) reshape routes and reads without adding a use case, a
table read by TypeScript or a roadmap slice, so the counts are the ones
measured at revision 111; PR 7 closes two findings of the plan, the category
tree tag that any item write invalidated and the item list that needed one
detail call per row for its image:

| Measure | Built | Of | Share |
|---|---|---|---|
| Use cases (`CATALOG_USE_CASES.md`, C1 to C28) | 23 | 28 | 82% |
| Catalog tables with TypeScript that reads or writes them | 20 | 37 | 54% |
| Roadmap slices (this section and §18 to §21) | 24 | 42 | 57% |
| Overall | | | about 58% |

What is left, in order (B2b, B2c, M2a, M2b, K16 and S1 are built since the counts above were measured; the counts are not re-measured here):

1. **K14c / K14d** (built, "K14c as built" and "K14d as built") — the text kind with its prefixes and print hints, and the reads and the judging of text selections. K14a (default quantities, migration `0108`, which also created `catalog.item_modifier_group_option_overrides`) and K14b (the per-offer option limits that read and write that table, no migration) are built.
2. **K13a / K13b** (built, "K13 as built") — structured variant dimensions; the matrix creates them, the item read shows them and the list filters by them.
3. **K15c–K15d** — master data (§17); K15a is built ("K15a as built").
4. **S1** (built, §22 "S1 as built") — menu time windows; holidays and date ranges stay deferred.
5. **K11** and **B3** — blocked: they need the Sales and Pricing modules, which do not exist.
6. **K12a–K12c** — offline: the change feed, the device bootstrap, the replay policy.
7. **C23** translations, **C24** food declarations, **C27** imports.
8. **C25** additional catalogs — deferred (OD4).

***

## 11. What the neighbours must do

**Sales:** reference offers only after K9b; call `admit` (K9e) last and hold
≤ 50 ms; store the offer `admit` returned (the effective-offer rule is aligned
since migration `0090`, X13); add `text_value` (X3), a representation of removed defaults (X4) and,
for offline replay, the `catalogVersion` (X7); bound `special_instructions`
(`0110_sales.sql:541`, `:999`).
**Commerce:** same admission; add the variant precedence and group sum
check (X2).
**Pricing:** price per `itemModifierGroupId` (exists); revival of an offer
reuses its prices (Pricing decides whether dated rows still apply); sequence
pricing, charge basis, percentage mode, removal credit are its own design
(X5, X6).
**Discounts, Inventory, Production, Tax, Kitchen, Operations:** as §5.3,
§4.5, §4.6, §4.10.
**Organization and Platform:** K10a–K10c.
**Sync and Events:** K12.

***

## 12. Contradictions and findings

| # | Finding | Evidence | Resolution |
|---|---|---|---|
| **X1** | Offers deleted and re-created on every edit; referencing FKs have no cascade | `modifier-group.queries.ts:158-175`; `0110_sales.sql:1229-1237`; `0100_commerce.sql:1048-1056`; `0090_pricing.sql:470-478` | K9a–K9c |
| **X2** | Commerce's trigger lacks variant precedence and group min/max | `0100_commerce.sql:1871-1990` | Commerce |
| **X3** | No column for a text option value in Sales or Commerce | `0110_sales.sql:1177-1307` | Sales, Commerce |
| **X4** | A removed default cannot be persisted | `0110_sales.sql:1271-1274` | Sales (Q3) |
| **X5** | Pricing's delta may be negative; Sales refuses negative charges; no removal credit | `0090_pricing.sql:424-425`, `:509-513`; `0110_sales.sql:1276-1280` | Pricing |
| **X6** | No charge basis beyond "per base unit of the line" | `0090_pricing.sql:484-491`; `0110_sales.sql:1198` | Pricing |
| **X7** | No Sales column for the admitted `catalogVersion` | `CATALOG_USE_CASES.md:1465` | Sales |
| **X8** | An extra with its own tax class is taxed in the line subject | `0260_tax.sql:1162-1180`; `0110_sales.sql:1807` | Tax |
| **X9** | Sales/Commerce accept inactive groups or targets; Catalog refuses them | `0110_sales.sql:4090-4095` | harmless while `admit` runs first |
| **X10** | Sales' kind check has no branch for `text` | `0110_sales.sql:4103-4118` | Sales must add a `text` branch before it consumes text options; K14c and K14d only publish them (Catalog-only: Sales does not exist yet) |
| **X11** | Production needs quantity 1 for remove/replace/scale | `0180_production.sql:6300-6304` | K14c prefix checks: `light`, `no`, `on_the_side` and `substitute` need `maxQuantity` 1; `extra` as a `scale` effect cannot be checked by Catalog because the effect is Production's (OV18) and Production refuses it at sale time |
| **X12** | `MODULE_CONTRACTS.md` names the lock timeout; the measured limit is the statement timeout | `MODULE_CONTRACTS.md:357`; `CATALOG_USE_CASES.md:1469` | K9e sentence update |
| **X13** | Sales ignores an inactive variant offer and falls back to the item offer (`img.status = 'active'` filters before `DISTINCT ON`), contradicting OV9's opt-out; it would require the item-level minimum on an opted-out variant | `0110_sales.sql:4200-4225`, `:4120-4134` (before `0090`) | **Closed** by K9g-0 (migration `0090`): Sales chooses the variant row among `active` and `inactive` first, then drops the group when it is `inactive`, and ignores `retired` |
| **X14** | Catalog names Organization and Platform tables; Organization writes Platform's table | §6.1 | K10 |
| **X15** | Version triggers miss menus, bundles, translations, `item_assets`, allergens, dietary tags, nutrition | `0080_catalog.sql:4139-4272` | add with each table's first use case (K12a rule); `item_assets` closed by migration `0095`, bundles by `0100` (B1, §19), menus by `0101` (M1, §21). **Checked at K12a** by a systematic query: every table the till and the sellable reads touch carries the triggers; the 17 tables without them (translations, allergens, dietary tags, nutrition) are read by nothing, and a database contract lists them so a table added without a decision fails |
| **X16** | No catalog events, no tombstones, external ids for variants only | §7.1 | **Closed by K12a** (events and tombstones, migration `0114`); other external mappings when a channel needs them |

**Not defects (qualified after the architect's review):** activation of
verticals without the plan (D15, §6.2); `salesQuantityStep` (published and
applied); preselections (already on the offer since `0080`, `is_default`
dropped by `0082`); `item_kind` (a proposal, OV21).

***

## 13. Minimum journeys (architect §14) as acceptance

| Journey | Proves | Slice that makes it runnable |
|---|---|---|
| Restaurant: two wing variants, one sauce group, different limits | effective offer and override | K9f |
| Restaurant: cheese × 2, a per-offer price and history | quantities, offer identity, snapshots | K9e + K11 |
| Retail: T-shirt size/colour, barcode and price by context | variants, attributes, composition | K13b + Pricing |
| Retail: 3,000 items and scanning offline | complete bootstrap, local index | K12b |
| Gym: membership + protein + T-shirt in one purchase | shared core, Gym's own consequences | future Gym vertical (criterion, not yet run) |
| Hotel: minibar charged to a stay | Hospitality integration without a room as an Item | Phase 10 (criterion) |
| Mixed organization: edit the master, offer per business | sharing without mixing assortments | built (K6), re-proven after K10b |
| Offline: sale before a retirement, then replay | explicit reconciliation | K12c |
| Concurrency: sales while another unit is edited | the real lock scope and admission budget | K11 (H8) |

***

## 14. What stays out

Prices, charge bases, discounts, stock and tracking, recipes, tax classes,
runtime availability and option allergens stay with their owners (OV18).
Portions and nested options are deferred (OV23). Per-outlet or per-variant
assortment waits for a business journey that needs it (architect §11).
Menus and bundles come last, by use case with real consumers (architect
§12, "Presencia de tablas SQL").

***

## 15. Open owner questions

| # | Question | Recommendation |
|---|---|---|
| **Q1** | Free-text options (engraving, cake message) in the first options delivery? They need Sales and Commerce columns (X3). | Yes, K14c–K14d, after K11 |
| **Q2** | When an ingredient runs out, should the till hide the instruction option at that outlet? | Yes, an Operations table beside the variant "86" |
| **Q3** | When a cashier deselects a default, must the kitchen ticket say "NO cheese"? | Yes: Sales persists `removedDefaults`; until then use "No X" options |
| **Q4** | Offline: accept a sale made before a retirement the device had not yet received (§7.3)? | Yes, on the four pieces of evidence; otherwise a conflict for a manager |
| **Q5** | Offline stock: allow oversell with later reconciliation, reserve an allotment per device, or block items without a confirmed balance? | Allow oversell with a flagged reconciliation for restaurants; allotments for retail items an owner marks as scarce |

Decided and no longer asked: per-variant offers (owner), unit quantity
semantics (Sales SQL), inactive override = opt-out (owner via coordinator).
Pricing's tiered prices and charge basis move to the Pricing design (X6).

***

## 16. Sentences `MODULE_CONTRACTS.md` will need (row of `CatalogSellableContract`, line 357)

Changed in the slice that changes the contract (K9e), never before.

* Replace "`admit` recibe líneas (como máximo 100, cada una con sus opciones
  elegidas, sin repetir)" with: "`admit` recibe líneas (como máximo 100),
  cada una con su variante, su cantidad y sus selecciones `{ optionId,
  quantity }` (como máximo 100 por línea, una opción una sola vez, cantidad
  entera de 1 a 999); juzga la cantidad de la línea contra la política de
  venta de la variante, la cantidad de cada opción contra su máximo efectivo
  y el mínimo y el máximo de cada grupo sobre la suma de las unidades, igual
  que el esquema de ventas."
* Add: "`resolve` y `admit` responden las ofertas efectivas de cada variante
  (`itemModifierGroupId`, `modifierGroupId`, `source` `item` o `variant`,
  mínimo y máximo), resueltas con una sola regla: la oferta activa de la
  variante reemplaza a la del artículo, la inactiva de la variante excluye el
  grupo y la ausencia hereda; la venta guarda la oferta que `admit` devolvió."
* Add: "Las ofertas nunca se borran ni cambian de identidad: editar sus
  reglas u orden conserva el UUID, quitar un grupo la retira (`retired`) y
  volver a ofrecerlo la revive con el mismo UUID; conservar el UUID no
  congela las reglas históricas, que cada consumidor guarda en su propia
  instantánea."
* Extend the refusals with `quantity_out_of_policy` and
  `option_quantity_above_maximum`, and the request problems with
  `invalid_quantity`.
* Replace "falla al vencer `DATABASE_LOCK_TIMEOUT_MS`" with "falla al vencer
  `DATABASE_STATEMENT_TIMEOUT_MS`" (X12).
* With K10: add rows for `OrganizationContextContract` and
  `BusinessUnitVerticalsContract`, and the normative exception of §6.2.

***

## 17. Column review of the catalog tables (owner requirement, 2026-10-07)

**Rule (OV18).** Catalog stores descriptive and classification facts and
stable ids only. A column passes the **coupling check** when it adds no FK
to another module's schema, repeats no fact another module owns, and
reaches neighbours only through `CatalogSellableContract` or their own
snapshots (as Sales does: `item_code_snapshot`, `display_name_snapshot`,
`sku_snapshot`, `0110_sales.sql:517-522`). Existing FKs to
`organization.organizations`, `organization.business_units`,
`reference.units_of_measure` and `assets.assets` stay; **nothing below adds
another one**.

### 17.1 `catalog.items` (`0080_catalog.sql:76-128`, `:3793-3827`, `:4490-4499`)

| Column | Verdict | Reason | Coupling check |
|---|---|---|---|
| `id`, `organization_id`, `catalog_id`, `code`, `name`, `status`, timestamps, idempotency and search-key columns | keep | — | existing FKs only |
| `description` | **change** (hardening, built in K15a, `0111`) | unbounded `text` (`:82`) while the API bounds it to 2,000 (`presentation/dtos/items/item-fields.ts:8`, `:36-38`): add the same bound in SQL | — |
| index `catalog_items_organization_status_idx` (`:123-128`) | **drop** after `EXPLAIN` proves it unused | superseded by `catalog_items_name_search_idx` (`:3978`), as `CATALOG_USE_CASES.md:1228` planned | — |
| `item_kind` | **Proposed only** (OV21): `goods` | `service` | `digital` | `rental`, added when one of its named readers ships: **Tax** (a service rate in some jurisdictions), **Invoicing** (fiscal line nature), **Inventory** (no stock link expected for a service), **Kitchen** (nothing to prepare for a digital item) | The review found no reader yet; the catalog already sells services (`CATALOG_USE_CASES.md:1109`) | it must never decide kitchen routing, stock, tax or membership inside Catalog; each reader applies its own rule to the snapshot value |
| `product_brand_id` | **add** (K15c): FK to new `catalog.product_brands` | manufacturer label for search and filters; distinct from `organization.brands` (`0020_organization.sql:211`) | FK inside `catalog` |
| regulatory flags | **added** (K15a, `0111`): `catalog.item_regulatory_flags (item_id, flag)`, `flag IN ('age_restricted_alcohol','age_restricted_tobacco','prescription_only','pharmacy_only','hazardous_goods')` | pharmacy, liquor, hardware | Catalog says *what* the product is; the rule belongs to Sales or a compliance module |
| lifecycle dates | **not added** (*Proposed*) | a second sellability rule beside `status` | — |
| weight, dimensions | **not added** | `inventory.items` (`0170_inventory.sql:141`) | — |
| stock tracking | **not added** | Inventory owns `lot_tracking_mode`, `expiry_tracking_mode` (`0170_inventory.sql:131-200`) | — |

### 17.2 `catalog.item_variants` (`0080_catalog.sql:140-260`, `:3841-3848`, `:3975`)

| Column | Verdict | Reason | Coupling check |
|---|---|---|---|
| identity, `code`, `name`, `sku`, `is_default`, `status`, timestamps | keep | the FK hub (`CATALOG_USE_CASES.md:172-180`) | — |
| `sales_unit_code` | keep | reference units with dimension and conversion (`0010_reference.sql:189-214`); packaging is Inventory's (`:186-187`) | existing FK |
| `minimum_sales_quantity`, `maximum_sales_quantity`, `sales_quantity_step` | keep; hardening `CHECK` that min and max are multiples of the step, built in K15a (`0111`) | **not a defect**: the step is published and applied (architect §12); the check only stops a policy that forbids its own minimum | — |
| `net_content_quantity`, `net_content_unit_code` | **added** (K15a, `0111`), both or neither; unit validated by the application against reference data, **no new FK** | label content (500 ml) | unit price is Pricing's |
| variant dimensions | see §8 (K13) | structured, not concatenated | inside `catalog` |
| `sale_capture` (IMEI at sale) | **not added** (*Proposed*) | a Sales capture rule, never a stock-tracking copy | — |
| weight, dimensions, cost | **not added** | Inventory, Procurement | — |

### 17.3 `catalog.item_variant_barcodes` (`0080_catalog.sql:269-345`, `:3976`)

| Column | Verdict | Reason | Coupling check |
|---|---|---|---|
| every column | keep | unique while active (`:3976`); one active primary (`:332-338`) | — |
| `barcode` | **change** (domain rule, no SQL) | validate the GS1 check digit for `ean13`, `ean8`, `upca`, `upce` | — |
| `units_per_scan` | **not added** | packaging is Inventory's (`:26`) | — |

### 17.4 Categories (`0080_catalog.sql:354-483`)

| Column | Verdict | Reason | Coupling check |
|---|---|---|---|
| every column of `categories` and `item_categories` | keep | hierarchy and effective visibility are correct (architect §11) | — |
| `categories.description` | **change** (bound, built in K15a) | unbounded (`:362`) | — |
| tax or kitchen meaning | **not added** | owners map categories themselves (`0150_kitchen.sql:637`) | — |

### 17.5 Modifier tables (`0080_catalog.sql:1366-1733`, `:4396-4488`)

| Table.column | Verdict | Reason | Coupling check |
|---|---|---|---|
| `item_modifier_groups.status` | **change**: add `retired` (K9a) | OV2 | keeps neighbour FKs valid without Catalog knowing them |
| `item_modifier_groups.item_variant_id` | keep, now used (K9f–K9g) | OV9 | — |
| `item_modifier_group_defaults.quantity` | **added** (K14a, `0108`) | OV16 | — |
| `item_modifier_group_option_overrides` | **added** (K14a, `0108`; read and written since K14b) | OV16 | inside `catalog` |
| `modifier_options.option_kind` (+`text`), `text_max_length`, `prefix`, `prints_on_receipt`, `prints_on_kitchen_ticket` | **change/add** (built in K14c, `0110`) | §4.5, §4.6 | hints only |
| `modifier_groups.description` | **change** (bound, built in K15a) | unbounded (`:1372`) | — |
| price, charge basis, recipe effect, stock link | **not added** | Pricing, Production, Inventory | — |

### 17.6 Assortment, `item_assets`, `catalogs`

| Table.column | Verdict | Reason | Coupling check |
|---|---|---|---|
| `business_unit_catalogs`, `business_unit_items`: every column | keep | — | existing FK to `organization.business_units`; reads move behind K10 |
| `…updated_at` on both | **add** with the next migration that touches them | R4 (`CATALOG_USE_CASES.md:1553`) | — |
| `business_unit_items.item_variant_id` | **not added** (*Proposed*) | per-variant assortment waits for a journey (architect §11) | — |
| `item_assets.catalog_id` | **add** in the A5b migration | every other link row carries it (`:3853-3880`) | inside `catalog` |
| `item_assets` other columns | keep | `ASSETS_STAGE_SPECS.md:1192-1208` | existing FK to `assets.assets`; no URL copied |
| `catalogs.code` pattern, `catalogs.updated_at` | **change/add** when OD4 brings more catalogs | R10 (`CATALOG_USE_CASES.md:1559`) | — |

### 17.7 Migrations for K15

* `<next>_catalog_master_data_hardening` (K15a): description bounds (items,
  categories, modifier groups) and the variant step check, each `NOT VALID`
  then `VALIDATE` after a check query finds no breaking row; net content
  columns; `item_regulatory_flags` with RLS (scalar subquery), bump
  triggers, `GRANT SELECT, INSERT, DELETE`. Expand-only.
* `<next>_catalog_product_brands` (K15c): `product_brands`,
  `items.product_brand_id`, RLS, triggers, grants. Expand-only.
* `<next>_catalog_variant_identifiers` (K15d): `item_variant_identifiers
  (item_variant_id, scheme, value)`, `scheme IN ('mpn','isbn',
  'manufacturer_code')`; channel ids stay in
  `integrations.external_mapping_catalog_variants` (`0350_integrations.sql:563`).
  Expand-only.
* `0114_catalog_change_feed` (K12a, built): `events.append_tenant_event`, nine
  `catalog.*.changed` event types with contract version 1, and the
  `catalog.record_change_feed()` triggers on 28 catalog tables. No table
  changes. Expand-only.

***

## 18. D1 design: what a new business unit sells

**Status: built (revision 109, migration `0099`).** The owner approved A1
together with verticals on O9 (18.4) on 2026-10-10. What was built is in
`CATALOG_USE_CASES.md` ("D1 as built") and `ORGANIZATION_USE_CASES.md` (O9);
this section keeps the design record: the facts, the options weighed and why
the others were rejected.

### 18.1 The decision and what exists

**Owner decision.** The assortment of a business unit is chosen when the
unit is created (`all`, `selected` or `none`). Without a choice, the default
is `none` for a unit whose vertical differs from the verticals of the
organization's existing units, and `all` otherwise (same vertical, or first
unit). Existing units keep selling what they sell today. It replaces OD5
(a) for new units.

**As built (revision 108):**

* A unit without a `catalog.business_unit_catalogs` row for the default
  catalog sells everything: `variant_is_listed`, `listed_items`, the
  `listed` filter and the till read treat "no row and a catalog in
  `organization` mode" as listed, and `assortmentModeOf(undefined)` answers
  `all` (`src/modules/catalog/domain/assortment/assortment.ts`).
* `none` is not a stored mode. It is an inactive row that keeps the mode it
  had (`storedModeOf`); `selected` with no listings also sells nothing.
* Catalog already owns a trigger on an Organization table:
  `organizations_catalog_version_insert_trg` creates the
  `catalog.catalog_versions` row (`DOMAIN_MAP.md`, row `0080_catalog`).
  `npm run lint:sql-ownership` judges SQL text inside `src/modules`, not
  canonical triggers.
* Every vertical writer serialises per organization on the advisory lock
  `organization.verticals:<organization id>`, taken before it reads or
  writes `platform.business_unit_verticals` rows: Platform's `activate` (its
  first statement, `lockOrganizationVerticals`), Platform's `deactivate`
  (before it locks the active rows), the last-vertical guard of a unit
  deactivation (`0020_organization.sql`) and this trigger. The lock belongs
  to the transaction, so a registration that activates its verticals and
  then runs the trigger at commit takes it again without waiting for
  itself, and releases it at commit. Proven by
  `platform-business-unit-vertical-locks.repository.integration-spec.ts` ›
  `makes a business unit registered with a vertical another unit is
  activating wait for that activation, then counts it`, and by
  `database/tests/concurrency/catalog-default-assortment-concurrency.ts`,
  which runs the repository's own SQL text
  (`business-unit-verticals-sql.ts`) instead of a lock of its own.

### 18.2 Blocker 1: a unit has no vertical when it is created

* Provisioning (`POST /organizations`) inserts the first unit and its
  verticals in one transaction (`platform.provision_organization`). That
  unit is always the first one, so its default is `all`.
* O9 (`POST /business-units`) carries only `code` and `name`
  (`RegisterBusinessUnitRequestSchema` is strict). Verticals arrive later,
  through O11 (`POST /business-units/{id}/verticals/{code}/activation`), in
  another transaction, or never.
* `organization.business_unit_is_operable` does not require a vertical: a
  unit with an outlet and no vertical can open a till and sell.

So for every unit after the first, "the vertical of the new unit" does not
exist when the unit is created. "Decide at creation and store it" and
"compare the unit's vertical" can both hold only if O9 receives the
vertical, or if the rule names a value for "no vertical yet".

### 18.3 Blocker 2: an explicit choice on the O9 request

An `assortmentMode` field on `POST /business-units` must reach Catalog in
the same transaction. Organization owns that route and must not depend on
Catalog; Catalog already imports `OrganizationModule`
(`OrganizationContextContract`), so the reverse import is also a module
cycle.

| Mechanism | Verdict |
|---|---|
| Organization calls a Catalog contract | Rejected: breaks the dependency direction and creates a Nest module cycle |
| Organization stores the requested mode in a column a Catalog trigger reads | Rejected: Organization would own a Catalog fact (`MODULE_CONTRACTS.md` §1) |
| A transaction-local setting (`set_config`) read by a Catalog trigger | Rejected: a hidden dependency that `lint:module-boundaries` cannot see |
| An extension point owned by Organization (an abstract class in its `contracts/` that Catalog implements and registers) | Not built: dependency inversion, but a collaboration mode `MODULE_CONTRACTS.md` §4 does not list; it needs an architecture decision first |
| A command composer outside both modules (Organization, Platform and Catalog contracts in one tenant transaction) | Clean, but it needs a new module and a new or moved route: out of D1's scope |
| An outbox event | Rejected: not atomic, the unit sells the organization catalog until the event is handled |
| The client sends the choice to Catalog's existing route `PUT /catalog/business-units/{id}/assortment` (C14) right after registering | **Recommended.** No new mechanism, audited by C14 (`catalog.assortment.mode_changed`); safe because the default of 18.4 is already safe for the request in between |

### 18.4 The default: options

**A1 (recommended).** A Catalog-owned deferred constraint trigger on the
insert of a business unit, evaluated at commit:

* `business_units_default_assortment_trg`: `AFTER INSERT ON
  organization.business_units DEFERRABLE INITIALLY DEFERRED FOR EACH ROW`,
  calling `catalog.materialize_default_assortment()`, `SECURITY DEFINER`
  with a fixed `search_path` and revoked from `PUBLIC`. It must be a definer
  because it also runs at the commit of provisioning, which has no tenant
  context, and of admin fixtures.
* At commit: a unit that already has a row for the default catalog (an
  explicit choice in the same transaction) is left alone. Otherwise it takes
  the `organization.verticals:<organization id>` advisory lock, so two
  registrations in one organization cannot both be first (the second one's
  next statement runs after the first commit and sees it), creates the
  default catalog if missing (the insert of `ensure_default_catalog`,
  without its tenant assertion), and inserts one row. `all` when no other
  unit of the organization has an active vertical (first unit), or when the
  unit has at least one active vertical and each of them is active in
  another unit of the organization; `none` (an inactive row of mode `all`)
  otherwise, which includes a unit with no vertical at commit.
* Stored once. Later vertical changes never touch it; only C14 does. The
  insert bumps the catalog version through the existing statement trigger,
  so the assortment and till ETags move.
* Audit: a SQL trigger cannot use `TenantAuditContract`. The default is a
  consequence of the audited `organization.business_unit.registered`
  record; later changes are audited by C14.
* Migration `0099` at revision 109, expand-only: the function, the trigger
  and a backfill that gives every existing unit without a row an explicit
  active `all` row on its organization's default catalog, creating the
  default catalog where an organization has none (one version bump per
  organization). The behaviour is identical: in a catalog in `organization`
  mode, no row and an active `all` row list the same variants
  (`variant_is_listed`). Reads keep "no row means `all`", so other catalogs
  and any row the backfill could not see answer as today.

With A1 and today's O9, every unit registered through `POST
/business-units` starts `none`, a second restaurant too. That is safe (it
sells nothing until someone curates it) but it is not the owner's "same
vertical, `all`". Two ways to close the gap:

* **A1 + verticals on O9:** an optional `verticalCodes` on `POST
  /business-units`; Organization activates them inside the registration
  transaction through `BusinessUnitVerticalsContract.activate` (already
  allowed since K10c) with the O11 audit records, and the trigger sees them
  at commit and applies the rule exactly. It changes the O9 API.
* **A2:** keep O9 as is and store the default at the first vertical
  activation instead (a Catalog trigger on the insert of an active
  `platform.business_unit_verticals` row), with "no row" meaning "sells
  nothing" for units created after `0099`. Not recommended: the mode a
  client reads moves from `none` to `all` when a vertical is activated,
  which the owner called a bug; and it changes the meaning of "no row" in
  four predicates (`variant_is_listed`, `listed_items`, the `listed`
  filter, the till read) and their differential contract.

**C (rejected).** A default computed at read time from the verticals
changes silently whenever a vertical is activated or deactivated.

### 18.5 What the build must prove

First unit, `all`; second unit with the same vertical, `all`; second unit
with another vertical, or with none, `none`, and its till sells nothing
until curated; an explicit C14 choice made in the same transaction or
right after wins in each of the three modes; two registrations raced in one
organization, exactly one first; an existing unit without a row keeps `all`
across the migration (upgrade test from revision 108, till list identical
before and after); the assortment routes and ETags still work; a curator
switches the mode and the till follows; another organization's units never
count. Mutations that must fail a named test: the default always `all`; the
default recomputed at read time; the backfill skipped; the advisory lock
removed.

### 18.6 Blast radius

* 13 test files insert business units through admin SQL (for example
  `addUnit` in `test/support/offerable-roles-fixture.ts`). With A1 every
  unit after the first without a shared vertical becomes `none` at commit,
  so assortment and till specs that add a second unit and expect it to sell
  must choose a mode or share a vertical.
* The workspace warning `units_with_different_verticals` keeps its meaning;
  its hint should say that such a unit starts at `none`.
* OpenAPI changes only if O9 gains `verticalCodes`.

### 18.7 Owner choice (decided 2026-10-10)

A1 + verticals on O9: `POST /business-units` takes the optional
`verticalCodes`, activated in the registering transaction, and the explicit
choice travels through C14 right after registering, not through a field on
O9.

***

## 19. B1: the composition of a combo

### 19.1 B1 design

**Decided by the owner (verified review).** A combo is a normal Item that
owns Bundle Groups (`0080_catalog.sql`, the comment above
`catalog.bundle_groups`): no item kind, no parallel product identity, so it
keeps its variants, categories, modifiers, images and its own price.
Catalog stores no price for it: Pricing computes the base price of the
item plus one surcharge per choice (`pricing.bundle_group_item_price_entries`).
A combo may contain another combo, with the SQL cycle guard. Retail kits
and service packages use the same concept with nothing restaurant-only.
Menus, schedules, admission (B2) and the Pricing and Sales integration (B3)
come later; sold out is Operations'.

**Shape, chosen for the consumer.** One task route per combo, the same
style as the modifier offers (C13) and the images (AC1): `GET` and `PUT
/catalog/items/{itemId}/bundle-groups`. The `PUT` replaces the ordered
groups and the ordered choices of each group in one call, because a
manager edits a combo as one screen and partial routes would let it pass
through states nobody wanted. Names follow the existing answers: ids are
`xxxId` (`bundleGroupId`, `choiceId`, `variantId`, `itemId`), the rule is
`minSelections` and `maxSelections` as on modifier offers, a preselection
is `isDefault`, an order is the position in the array (no `sortOrder`
field). A row of `bundle_group_items` is called a **choice**: what the
customer chooses in a group. Each choice answers the item and variant
names, the SKU and both statuses, so a screen needs no second call;
`isAvailable` and `suspended` on each choice, and `isSatisfiable` and
`suspended` on the group, tell the manager what the till will not be able to
sell. Quantities are decimal strings, as the sales quantities of a variant.

**Identity (the K9b rule).** Sales, Commerce and Pricing reference both
tables by id (`sales.order_line_bundle_selections`,
`commerce.cart_item_bundle_selections`, `pricing.bundle_group_item_price_entries`,
all `NO ACTION`), so a group or a choice is never deleted: what the list
leaves out is retired, and sending it again revives the same id. A group is
identified by its id; a choice by its (group, variant) pair, the existing
unique key, so it keeps its id even when the client forgets `choiceId`; a
`choiceId` sent with another variant is refused instead of silently moving
a priced or sold row to another product. The CHECK of both tables is widened
to `retired` (expand-only, as K9a); the runtime gets no `DELETE`.

**Validation.** Everything that needs no lookup is a `400` at the schema
with the field (`bundleGroups[i].choices[j].variantId`, the format of every
other validation answer); what needs the database is a typed refusal with
`meta.errors[]` like the images bulk route. Limits reuse existing ones: 20
groups (as offered modifier groups), 50 choices (as options per group), 999
selections (Sales' `selection_count`), a quantity at most 999.

**Version and locks.** The version triggers are added to both tables (X15,
the K12a rule: with the first use case). The command takes the version row,
then the combo item with its composition (one statement, `FOR UPDATE OF`
the item), then the requested variants `FOR SHARE` in id order, the order
of every catalog command; an admission holds the version row `FOR SHARE`,
so an edit waits for open sales (K8).

**The item answer (C3) is unchanged.** Adding `bundleGroups` to the item
would change the bytes and the hash of every item and add a statement to
every item read and write; the composition lives on its own route with its
own strong ETag, and `If-Match` on the `PUT` uses that tag, not the item
tag (which does not cover the composition).

### 19.2 B1 as built (migration `0100`, baseline revision 110)

As designed; the use case, the answers, the example and the proofs are in
`CATALOG_USE_CASES.md`, "B1 as built", and the C22 rows of
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md). Migration `0100` widens
the two status CHECKs, names the cycle guard (`bundle_composition_acyclic`),
adds the six version triggers, grants `SELECT`, `INSERT` and `UPDATE` on
both tables and `EXECUTE` on the two graph functions the triggers call as
the invoker, and seeds `catalog.item.bundle_groups_set`. After review the
cycle guard ignores retired groups and choices (a retired choice must not
forbid a later nesting), its triggers also fire on `UPDATE OF status` so a
revival is checked, and the use case's lookup of the combos that contain a
variant reads the same live graph. Repetitions are refused by the request
schema before any lookup, the ids are judged before the unchanged-composition
answer, and a row suspended in SQL stayed suspended when the list was written
again (until the standardization below made suspension writable). B1 left
three questions for the owner, now closed: suspending a group or a choice
from the API and a cap per choice (`max_selection_count`) were taken by the
[standardization after M1](#standardization-after-m1-no-migration-baseline-revision-111),
and a component that is itself a combo with a group that cannot be completed
makes its parent's choice unavailable since F1b (§20.3, down to the third
combo level). Still open: whether descriptions or translations of a group
are needed.

***

## 20. B2: admitting a combo

### 20.1 B2 design

**The problem.** B1 lets a manager say what a combo is made of; nothing
yet lets a sale say which components the customer took. `admit` judged only
modifier selections, so Sales could not freeze
`sales.order_line_bundle_selections` from a Catalog answer, and the
`0110_sales.sql` triggers (`validate_line_bundle_insert`,
`assert_order_line_bundle_consistency`) would be the first place a wrong
choice is caught, with a `23514` instead of a typed refusal.

**Shape, additive.** An admission line gains an optional
`bundleSelections`; nothing else in the contract changes, and a line without
it, or a line of an item without active groups, is admitted and serialized
exactly as before (no `bundleGroups` key). The input mirrors the modifier
input one level up, with the business names B1 already uses:

```text
lines[i].bundleSelections: [{               // per group, each group once per level
  bundleGroupId,
  choices: [{                               // in the order the customer chose
    choiceId,                               // the B1 choice (group, variant)
    quantity,                               // integer 1..999, Sales' selection_count
    selections?: [{ optionId, quantity }],  // modifiers of THIS component
    bundleSelections?: [...]                // the groups of a combo inside the combo
  }]
}]
```

The admitted line answers `bundleGroups`, every active group of the combo in
catalog order, mirroring `offers`: `{ bundleGroupId, groupNameSnapshot,
minSelections, maxSelections, choices, removedDefaults }`, each choice
`{ choiceId, quantity, quantityPerSelection, appliedByDefault, snapshot,
offers, bundleGroups? }`. `snapshot` is the component's own sellable
snapshot (item and variant names, SKU, sales unit), `offers` its own
admitted modifier offers, `bundleGroups` its own tree when it is a combo.
That is every column Sales freezes (table in §20.2); Catalog still answers
no price.

**Judgement, the same rules as modifiers and as Sales.**

* A group must be a live group of that combo (`bundle_group_not_offered`)
  and active (`bundle_group_unavailable`); a choice a live choice of that
  group (`bundle_choice_not_offered`) and active (`bundle_choice_unavailable`).
  Sales refuses the same rows (`0110_sales.sql:4436-4446`).
* Minimum and maximum on the **sum of the chosen quantities**, as Sales sums
  `selection_count` (`0110_sales.sql:4526-4542`) and as modifier groups sum
  units (`bundle_selection_below_minimum`, `bundle_selection_above_maximum`).
  The same choice may be sent twice in a group: two occurrences, two Sales
  rows (`selection_number`), for "Burger #1 no onion, Burger #2 extra
  cheese" (the comment above the Sales table).
* A choice's own cap, `max_selection_count`, on the sum of its entries
  (`bundle_choice_above_maximum`); Sales checks it per row.
* A required group whose available choices cannot reach its minimum
  (B1's `isSatisfiable`: choice, item and variant `active`, each worth its
  cap or 999) refuses `bundle_group_unsatisfiable` and its sum is not judged.
* Each component is judged by the **same sellable rule** as a line
  (`sellableVariant`: item and variant active, listed at the business unit,
  required modifiers satisfiable), read by the same `SELLABLE_FACTS`
  statement, and the refusal carries that line reason (`not_listed`,
  `item_inactive`, ...). Its modifier selections are judged against its own
  effective offers by `judgeSelection`, the K9f rule.
* `quantity_per_selection` is not judged: it is the component amount per
  selection (B1 answers it as the choice `quantityPerSelection`; it was
  `quantity` until the standardization after M1), copied into
  `quantityPerSelection` for `quantity_per_selection_snapshot`. The
  component sales policy is not judged either, as Sales does not.

**Defaults.** A group the client leaves out gets its available defaults,
one each (`appliedByDefault: true`); a group the client sends gets exactly
what it sends, and the available defaults it left out are answered in
`removedDefaults`, as modifier offers do. Why not mirror modifiers exactly
(defaults never applied): a modifier default is a preset the till shows
ticked, but a combo default is a component the customer receives and Sales
must store a row for; a quick "Combo" key at the till must sell the default
drink without the till re-reading the composition. B1 already keeps the
defaults of a group between its minimum and its maximum, so applied defaults
fit. A combo sent without `bundleSelections` whose required group has no
default refuses `bundle_selection_below_minimum` for that group, the same
reason as a missing required modifier group. A default counts as available
by the same rule the till applies to a choice in the unit and outlet of the
call (active, listed at the unit, not hidden at the outlet sent): one that
is delisted or hidden is simply not applied and no other choice takes its
place, so an optional group is admitted with no choice and a required one
refuses `bundle_selection_below_minimum`; a choice the client sends that is
no longer sold keeps refusing `not_listed`.

**Nesting and bounds.** A choice that is itself a combo is judged
recursively with its own `bundleSelections` and defaults, as Sales checks
every component (`0110_sales.sql:4505-4542`). The cycle guard only promises
the graph ends; admission adds a depth of 3 (`CATALOG_SELLABLE_LIMIT.BUNDLE_DEPTH`:
a component of a combo of a combo of a combo; "family, kids, drink" uses 2)
so the statement count is bounded: a request deeper than that is a request
problem (`too_deep`), a catalog deeper than that (through defaults) refuses
`bundle_too_deep`. At most 100 components per line
(`COMPONENTS_PER_LINE`, request problem `too_many_components`, also at most
20 groups per level), and `bundle_components_above_limit` if defaults expand
beyond it. Admission counts the components of each line while it builds each
level and stops expanding that line as soon as the count passes 100: the line
is refused at once and no deeper level is read for it, so one read carries at
most 100 components per line whatever the fan-out of the defaults (nested
defaults are not shared between branches, so 20 groups of 50 defaults would
otherwise multiply level by level).

**Refusals.** Whole or nothing as today, in `CatalogNotSellableError`. A
refusal inside a combo is a `BundleAdmissionRefusal`: the usual `line`,
`variantId` (the line's), `reason`, `modifierGroupId`, `optionId`, plus
`path` (`lines[0].bundleSelections[1].choices[0]`), `bundleGroupId`,
`choiceId` and `detail`, a sentence that says how to fix it. A group or a
default the client did not send points at the list where it belongs
(`lines[0].bundleSelections`): that is where the fix goes.
`isBundleAdmissionRefusal` narrows it. Line refusals keep their exact shape.

**Statements and locks.** The groups of every variant come in the same
`SELLABLE_FACTS` statement (`bundle_groups`, one indexed probe); the
components of all lines are read with that same statement, once per level
of nesting, whatever the number of groups or lines: a combo costs one more
statement, a combo in a combo two. Still one catalog version share lock;
a B1 composition edit waits for open admissions (K8).

**`resolve` (B2b, not built).** Showing a combo's groups with availability
on `resolve` would change the snapshot bytes of every variant (the snapshot
is shared with `admit` and the till) or need a second statement with the
component sellability; the till already reads the composition from B1's
`GET`. Left for B2b with the till slice.

### 20.2 B2 as built (no migration, baseline revision 110)

As designed. The use case, the Sales mapping, the example and the proofs are
in `CATALOG_USE_CASES.md`, "B2 as built", and the C28 rows of
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md). Open for the owner:
whether the depth of 3 is right; whether a group the client sends empty on a
combo with defaults should mean "none" (built) or "defaults"; whether a
component's sales quantity policy should bound `quantity ×
quantityPerSelection`. Whether `bundle_group_unsatisfiable` should also make
`resolve` and the till call the combo not sellable is closed: B2b (§20.3)
does.

### 20.3 B2b as built (no migration)

A combo whose required group cannot be completed is no longer sold anywhere
while `admit` refuses it. The shared sellable rule
(`infrastructure/reads/sellable-rule.queries.ts`) adds one fragment,
`bundleComposable`, to `sellableCandidate`: no live group of the item with
`min_selections > 0` that is not active, or whose available choices (an
active choice of an active variant of an active item) cannot reach the
minimum in units, the sum of their `max_selection_count` (999 when absent).
It is the one rule B1 answers as `isSatisfiable`
(`domain/bundles/bundle-satisfiability.ts`), checked against the domain on
the same rows by `bundle-composable.integration-spec.ts`. Since F1b it is
recursive: a choice whose component is itself a combo is available only when
that combo can be composed too, down to the third combo level; below it, a
component combo the sale would have to open is not available, as `admit`
refuses it with `bundle_too_deep` ("F1b as built" in `CATALOG_USE_CASES.md`).
The domain twin is `isComposable` (`domain/sellable/bundle-composability.ts`).
Since F1c an available choice also needs its component to be sellable on its
own at the reader's unit, listed there and with its required modifiers
satisfiable, the facts `admit` refuses it on ("F1c as built" in
`CATALOG_USE_CASES.md`).
It is written as a keyed
scalar probe (`anyRowOf`, never `EXISTS`) on `bundle_groups_item_idx` and
`bundle_group_items_group_idx`, so the plan does not depend on fresh
statistics; the three levels are unrolled in the SQL text, never a recursive
query.

| Reader | Answer for a combo that cannot be completed |
|---|---|
| Till list (C17), search (C19), scan (C18) | left out, like an item that does not exist at the outlet; `omittedItems` does not count it |
| `resolve` | `sellable: false`, `refusal: bundle_group_unsatisfiable` (new value of the closed line domain, after `required_modifier_unsatisfiable`), from a new `composable` fact read in the same statement |
| `admit` | the bundle judgement explains it with the group and the path: `bundle_group_unavailable` for a suspended group, `bundle_group_unsatisfiable` for one whose choices cannot reach the minimum; if the groups it reads raise nothing, the line is refused `bundle_group_unsatisfiable`, never admitted |

A plain item and a combo whose groups can be completed answer exactly the
same bytes as before. Statement counts do not change: the fragment and the
fact live inside the statements that already read the candidates.

### 20.4 B2c as built (no migration, baseline revision 114)

"B2b" names the sellable rule of §20.3; B2c is the till side. A cashier
holds `catalog.sellables.view` but not `catalog.items.view`, so it could not
see what a combo holds. The till read (C17) and the till search (C19) now
answer `bundleGroups` on every item with an active group, using the field
names of the B1 read: the active groups, each with only the choices `admit`
accepts now at that outlet; a plain item has no key at all, byte for byte.
The per-choice check is one exported fragment, `choiceAvailable`, which
`bundleComposable` itself counts each choice with, so the set of choices the
till shows and the set the rule counts are the same SQL text. A choice names
its item, itself in `items` with its own `bundleGroups`, so nested trees are
never duplicated; inside the third combo level the till offers only choices
whose item has no `bundleGroups` key. No route, migration or statement is
added; the ETag already moves with the catalog version. Details, the JSON
and the measured cost in `CATALOG_USE_CASES.md`, C17 and "B2c as built".

## 21. M1: the structure of a menu

### 21.1 M1 design

**Decided by the owner.** A menu says *what is shown*: ordered sections,
subsections and the items each one shows. Catalog owns its structure and,
later (M2), which outlet shows which menu; Pricing owns the price by menu or
time (`pricing.price_book_assignments`, `price_rules.menu_id`), Operations
owns sold out, and Commerce keeps only the online storefront publication.
Two menus may later be active together on one till in a fixed order (M2),
and a line keeps the time it was added (S1). A retail kit or a service
package uses the same menus: nothing in M1 is restaurant-only. Out of M1:
outlet assignment (M2), time windows (S1), translations, tags, the till read
by menu and admission by menu.

**Shape: a few task routes, one replace for the tree.** A manager builds a
menu as one screen, so the tree is written in one call, like the combo
(§19.1): `PUT /catalog/menus/{menuId}/structure` replaces the ordered top
sections, their subsections and the ordered entries of each section. The rest
are small task routes: list (`GET /catalog/menus`), create (`POST`, with an
`Idempotency-Key` like every catalog create, a draft), read (`GET
/catalog/menus/{menuId}`), rename (`PATCH`, name or code), and the two
lifecycle routes `POST .../activation` and `POST .../archival`. Seven
operations (six since API v7 PR 3, which folds the lifecycle into one
transitions route with `unpublish` and `restore`).

| Question | Decision and why |
|---|---|
| Depth | Two levels: a section and its subsections (`Entradas` › `Frías`). The SQL allows any depth with a cycle guard; two levels cover a restaurant card and a retail catalogue page, and a fixed depth keeps the request and the screen simple. A deeper menu is a later, additive change |
| Names | `menuId`, `sectionId`, `entryId`, `itemId`, `variantIds`, `variantMode` (`all` or `selected`, the SQL words); order is the position in the array (no `sortOrder`) |
| Section identity | `sectionId` when sent, else the section that holds the `code` sent (retired ones included), else a new id. A section keeps its id when it moves (top ↔ subsection); Kitchen rules name sections (`kitchen.*.menu_section_id`), so moving one keeps its routing. Each requested section is resolved to its stored identity before anything else, and two that resolve to the same one are refused as a repetition at the alias field |
| Entry identity | An entry is the pair (section, item), the SQL unique key: an item sent again in the same section keeps its entry even without `entryId`. An entry does not move between sections; `entryId` sent under another section or with another item is refused (`catalog.menu_entry_mismatch`) rather than moving a sold row (`sales.order_lines.menu_entry_id`) |
| Retire, never delete | Sales (`order_lines.menu_entry_id`), Commerce (`cart_items`), Kitchen and Pricing reference menus, sections and entries, so what the tree leaves out is retired (`status = 'retired'`, widened CHECK) and sending it again revives the same id. The runtime gets no `DELETE` on them |
| Selected variants | `menu_entry_variants` has no status and nothing references it, so a changed selection deletes and inserts rows; the runtime gets `DELETE` on that table only. The deferred SQL check (selected needs one variant, all needs none) stays the backstop |
| Eligible items | A new entry shows an item of the menu's catalog that is not archived; a newly selected variant must not be archived. An entry already in that section keeps its place if its item is archived later (as B1) |
| Derived state | Each entry answers `isAvailable` (entry, section chain and item active, and one offered variant active); the menu answers `entryCount` and `availableEntryCount`. No money, no sold out |
| Lifecycle | `draft` → `active` (activation, gated by the plan; refused with a typed reason when the menu has no entry, `catalog.menu_empty`, or none available, `catalog.menu_nothing_available`) → `archived` (never gated, final, read-only). An active menu cannot be emptied (archive it instead). Back to draft is not in M1 |
| Limits | 100 menus per organization, archived ones included (the list is never paged); 50 sections and subsections, 200 entries per section, 1,000 entries per menu, 50 selected variants per entry |
| Version, locks, audit | Version triggers on the four tables (X15 for menus). Every command takes the version row, then the menu with its tree in one statement `FOR UPDATE OF` the menu. One audit action per command: `catalog.menu.created`, `.updated`, `.structure_set`, `.activated`, `.archived` |
| ETag | A strong hash of the menu answer, item and variant names and statuses included; `If-Match` on every write of the menu, `If-None-Match` answers `304` |

### 21.2 M1 as built (migration `0101`, baseline revision 111)

As designed; the routes, the answers, the example and the proofs are in
`CATALOG_USE_CASES.md`, "M1 as built", and the C21 rows of
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md). Migration `0101` widens
the two status CHECKs, adds the creation key and hash to `catalog.menus`,
adds the twelve version triggers, grants the runtime `SELECT`, `INSERT` and
`UPDATE` on `menus`, `menu_sections` and `menu_entries`, `DELETE` too on
`menu_entry_variants`, and `EXECUTE` on
`catalog.assert_menu_entry_variant_configuration(uuid)` (the deferred entry
triggers call it as the invoker), and seeds the resource kind `catalog.menu`
and the five audit actions. Open for the owner: whether an active menu may go
back to draft; whether `display_name_override`, `description_override`,
`default_variant_id` and the menu description are needed on the screen;
whether archived menus should stop counting toward the 100. Whether a section
or an entry may be suspended from the API is closed: the standardization
below takes `suspended` on both.

***

### Standardization after M1 (no migration, baseline revision 111)

The owner closed the questions B1 and M1 left open and asked the three
slices to speak one API:

* **Suspension from the API.** `PUT .../bundle-groups` takes `suspended` on
  a group and on a choice, `PUT /catalog/menus/{menuId}/structure` on a
  section and on an entry, with the modifier offer's meaning: `true` writes
  `inactive`, `false` or absent writes `active` (so a suspended row resumes
  and a retired one revives as sent). The answers carry `suspended` instead
  of `status`; `retired` stays internal. A required suspended combo group
  leaves the combo unsellable (`bundle_group_unavailable` at the list), an
  optional one is not offered; a suspended section takes its entries out of
  `availableEntryCount`. Suspending is gated like any other change.
* **A cap per choice.** `maxSelectionCount` (1 to the group's
  `maxSelections`, absent for no cap) is written to `max_selection_count`,
  so `bundle_choice_above_maximum` is reachable. B1's `isSatisfiable` and
  B2's `bundle_group_unsatisfiable` are one function
  (`domain/bundles/bundle-satisfiability.ts`): a required group is satisfiable when it
  is not suspended and its available choices, each counted up to its cap,
  reach the minimum.
* **One name per meaning.** B1's choice `quantity` is `quantityPerSelection`
  (the column and B2's snapshot already said so); B2's choice `quantity`
  stays the number of picks. Modifier options answer and take `variantId`
  (was `itemVariantId`), as combos and menus do.
* **Codes.** A new combo group, menu section or menu without `code` takes one
  derived from its name, like items and modifier options, instead of its id.
* **Lifecycle and documentation.** Activating an archived menu answers
  `catalog.transition_invalid` like every lifecycle route; `PATCH` on a menu is
  "Edit a menu" (`update`); the menu reads document their own `404`s.
* **Tables.** None changed: `quantity_per_selection`, `max_selection_count`,
  `item_variant_id` and the `active` / `inactive` / `retired` vocabulary are
  already the same in `bundle_groups`, `bundle_group_items`, `menu_sections`,
  `menu_entries` and `item_modifier_groups`, and each maps one to one to the
  API name.

OpenAPI moves to `6.0.0` (the renames are breaking, accepted while nothing
is in production).

### API v7 PR 2 as built (no migration, baseline revision 111)

Items and variants change status through one route each,
`POST /catalog/items/{itemId}/transitions` and
`POST /catalog/items/{itemId}/variants/{variantId}/transitions`, with
`{ "transition": "deactivate" }` (or `reactivate`, `archive`, `restore`)
and a lean answer, `{ itemId, status }` or `{ variantId, status }`, plus the
item ETag. An archived variant can now be restored (to `inactive`), so
archiving a variant is no longer final; a variant that sells as a modifier
option (`catalog_variant`) still loses its offer defaults on deactivation
and archival, and a restore brings none back. Choosing the default variant
moved to `PATCH /catalog/items/{itemId}` with `defaultVariantId`. Details in
`CATALOG_USE_CASES.md`, "API v7 PR 2 as built".

### API v7 PR 3 as built (migration `0102`, baseline revision 112)

Categories, modifier groups, modifier options and menus change status through
one route each, `POST .../transitions` with `{ "transition": ... }`, and
answer `{ <id>, status }` with the ETag of the category, the group or the
menu. Archiving is no longer final for any of them: `restore` brings a
category, group or option back as `inactive` and a menu back as `draft`;
`unpublish` takes an active menu back to `draft`. Moving a category folded
into `PATCH /catalog/categories/{categoryId}` with `parentCategoryId`.
Migration `0102` seeds the six missing audit actions, and a variant restore
is now recorded as `catalog.item.variant_restored`. Details in
`CATALOG_USE_CASES.md`, "API v7 PR 3 as built".

### API v7 PR 4 as built (migration `0103`, baseline revision 113)

The barcodes of a variant are replaced as one set,
`PUT /catalog/items/{itemId}/variants/{variantId}/barcodes`, under a
required `If-Match` on the item tag: a barcode keeps its id, one left out is
archived (status `archived`, migration `0103`), never deleted, and
`suspended` keeps one inactive; one `catalog.item.barcodes_set` record per
change replaces the per-barcode added, updated, deactivated and reactivated
records, and the four per-barcode routes are gone. The images of an item and of its variants are set through one route,
`PUT /catalog/items/{itemId}/images` with `{ images, variants? }` under one
required `If-Match` on the item tag; a variant not sent keeps its images,
and `PUT /catalog/items/{itemId}/variants/{variantId}/images` is gone. Each
entry of the bulk `PUT /catalog/item-images` may carry `ifMatch`, and a stale
one refuses the whole request with `412`. Modifier offers are untouched.
Details in `CATALOG_USE_CASES.md`, "API v7 PR 4 as built".

### API v7 PR 5 as built (no migration, baseline revision 113)

A modifier group and its whole option set are replaced in one
`PUT /catalog/modifier-groups/{modifierGroupId}` under a required `If-Match`
on the group tag, replacing the `PATCH` of the group and the `POST` and
`PATCH` of an option: an option keeps its id when sent back or when its code
is sent again, one left out is archived, never deleted, `suspended` keeps one
inactive, the kind and the variant of an option never change
(`catalog.modifier_option_target_fixed`), refusals point at the option, and
one `catalog.modifier_group.updated` record keeps the live options before
and after. The options transitions route stays for taking one option off
sale. The item offers and the overrides of its variants (OV1 to OV3) travel
in one `PUT /catalog/items/{itemId}/modifier-groups` with `{ offers,
variantOverrides? }` under the item tag: a variant not sent keeps its
overrides, one sent with an empty list clears them, and the variant route
is gone. Every rule of §4 is unchanged. Details in `CATALOG_USE_CASES.md`,
"API v7 PR 5 as built".

### API v7 PR 7 as built (migration `0104`, baseline revision 114)

Listing and delisting at a business unit travel in one
`POST /catalog/business-units/{businessUnitId}/assortment/listing-changes`
with `{ list, delist }` (up to 200 ids each, never the same id in both);
the two former routes are gone (Catalog 43 to 42 operations). Every rule of
C15 is kept: the plan gates the change only when `list` names an item, an
archived item is refused only in `list`, `If-Match` stays optional, and one
`items_listed` and one `items_delisted` record are kept, each only when its
direction changed. `GET /catalog/items` takes several statuses
(`status=active,inactive`), `includeDescendants` with `categoryId` and
`hasImage`, and every row, the workspace first page included, carries
`primaryImage` `{ assetId, thumbnailUrl }` or `null`, from one Assets call
per page; every filter is bound into the cursor and a page stays 7
statements (8 with images). Both category filters now read the classified
items once per statement, which keeps a page under 2,500 rows read on stale
statistics instead of about 88,000. Migration `0104` gives the category tree
its own version, `catalog_versions.categories_version`, moved only by
category writes, and `GET /catalog/categories` derives its tag from it.
OpenAPI moves to `13.0.0`. Details in `CATALOG_USE_CASES.md`, "API v7 PR 7
as built".

### M2a as built (migration `0105`, baseline revision 115)

Which menus an outlet shows, and in what order, read and replaced as one
list: `GET` and `PUT /catalog/outlets/{outletId}/menus` with
`{ "menuIds": [...] }` (Catalog 42 to 44 operations). The owner decided that
these menus only organize: they never limit what the outlet sells, the
assortment and the sellable rule stay the single rule, and `admit` is
unchanged. Migration `0105` adds `catalog.outlet_menus` (position 0 to 9,
each menu once per outlet, composite foreign keys to the outlet and the menu
of the same organization), whose rows are links deleted and inserted again;
its statement-level triggers move the catalog version, so the till tag
changes with the assignment. The write needs `catalog.assortment.manage` at
the outlet and, like the sibling assortment writes, the plan for any list
but the empty one, so taking every menu off an outlet always works; the read
needs `catalog.items.view` or `catalog.assortment.manage` at the outlet.
`If-Match` is required on the write. A menu that starts being shown must be
a draft or an active menu of the organization (`404 catalog.not_found`,
`422 catalog.menu_archived`, pointed at `menuIds[i]`); one already shown
keeps its place when archived later, and archiving or unpublishing it keeps
working, serialized through the catalog version row both take first. One
`catalog.outlet.menus_set` record per real change keeps the ids before and
after. OpenAPI moves to `13.1.0` (additive). Details in
`CATALOG_USE_CASES.md`, "M2a as built" (C29). Next: M2b, the till read
grouped by the menus of the outlet.

### M2b as built (no migration, baseline revision 115)

The till snapshot, `GET /catalog/outlets/{outletId}/sellable-items`, gains a
top-level `menus` key: the active menus assigned to the outlet, in its order,
with their active sections and subsections and the entries whose item is in
the same answer's sellable set, by ids only (`variantIds` only on a
`selected` entry, and only its active configurable variants). Empty sections
and menus are dropped; an outlet without menus answers `menus: []` and every
other key is byte for byte as before (a golden e2e). Menus still never limit
what sells, and neither C19 nor C18 carries them. No migration and no route:
the menus are composed inside the existing statement, so the `200` stays at 8
statements and the `304` at 7, and the tag already moves with every menu and
assignment write. The item check is one lookup in a set built once from the
statement's own `sold` set, which keeps the SQL equal to the sellable rule by
construction and cheap to plan; a plan spec on stale statistics bounds it per
shown entry. The payload grows about 9 % for a 300-item restaurant with two
menus. OpenAPI moves to `13.2.0` (additive). Details in
`CATALOG_USE_CASES.md`, "M2b as built".

### K16 as built: outlet hidden items (migration `0106`, baseline revision 116)

The owner decided (OV24) that the catalog is abstract, administered per
business unit or per outlet, with no per-channel flags, and that the business
unit stays the unit of assortment. Several outlets may share one unit, and
until K16 nothing said "this item is not sold at *this* outlet". Option A
was chosen: a per-outlet layer of **hide-only** exceptions. The effective
sellable set of an outlet is the assortment of the business unit that
operates it today minus the items it hides. An outlet can never sell what
its unit does not list; hiding an item hides all its variants; the rows stay
when the outlet moves to another unit and simply apply to the new unit's
assortment. Menus remain presentation: they never limit what sells.

Migration `0106` adds `catalog.outlet_hidden_items` (`organization_id`,
`outlet_id`, `item_id`, `created_at`; primary key in that order, composite
foreign keys to `organization.outlets` and `catalog.items`, the same
technique as `outlet_menus`), its three statement-level version triggers,
RLS with scalar subqueries, `SELECT`, `INSERT` and `DELETE` for the runtime,
the audit action `catalog.outlet.hidden_items_set` and
`catalog.variant_is_listed_at_outlet(org, unit, outlet, variant)`, which is
`catalog.variant_is_listed` plus two checks, that the unit operates the outlet
today and that the outlet does not hide the item, without touching any
existing signature. The primary key is the only index: the till probe asks
for one `(organization_id, outlet_id, item_id)`, and items and outlets are
never deleted, so a reverse index would serve nobody.

One shared SQL fragment, `hiddenAtOutlet`, lives in
`sellable-rule.queries.ts` and rides the `SellableScope` (a new optional
`outlet`). `listedInScope` composes it with the assortment, so
`listedCandidate`, the component of a combo at every level
(`componentSellableAt`) and the `listed` column of `resolve`/`admit` all
judge it once, in the same text; the till list and search take the outlet
they already carry (`$4`), the scan its resolved outlet. It is a keyed
anti-lookup per candidate item (`anyRowOf`), never an `IN` over the table.
Correction (K16b, owner decision): PR #597 said that a combo or an option whose
component is hidden at an outlet is unavailable there. For options that was
not true, and the line this paragraph used to carry ("not judged by listing,
kept on purpose") is withdrawn. A local must never sell what its business
unit does not list or what its outlet hides, so a modifier option whose
target is an item is judged like the component of a combo: its target must be
active, listed by the unit, not hidden at the outlet and a plain item (an
item that opens no combo group, so a combo, composable or not, is not sold as
an option). `availableOption` takes the same scope as the rest of the sellable
rule (organization, unit, optional outlet) and judges the target with the
existing listing and hiding fragments, written once in `targetSellable`: where
the option is read (the scan and the filter of the search), or in a `jsonb` set
that the statement builds once for the many options it shows (the list, the
page of the search, `resolve`); the structural `activeOption` stays for the
writes, which have no unit. Without an `outletId` only the delisting applies.
The write path refuses what the read no longer offers: creating a group or
adding an option (the set) whose `variantId` belongs to a combo, an item with
a live bundle group, answers `422 catalog.option_target_not_plain`, judged in
the same transaction as the write, in the statement that share-locks the
variant and its item (the composition command locks the item row for update, so
the two serialize; a composition that commits just before the lock is the same
as one right after the write, which the reads neutralize). Rows written before this rule that point to a combo stop
being offered; nothing rewrites them and the repository holds no fixture, seed
or test that uses a combo as an option target (an offering item that is a
combo is a different thing and is unaffected). A plain item that is composed
into a combo after it became a target stops being offered the same way; there
is no database constraint, which would need a migration.
Details in `CATALOG_USE_CASES.md`, "K16b as built".

`CatalogSellableContract.resolve` and `admit` accept an optional `outletId`.
With it, a hidden item is refused as `not_listed` (the vocabulary of an
unlisted one), and an outlet that does not exist, belongs to another
organization or is not operated by the given business unit on the date of its
site is refused in the same statement with the new reason
`outlet_not_operated_by_unit`, right after `business_unit_unavailable`, so
hiding is never skipped by a wrong outlet; a malformed outlet answers as no
unit. Without an `outletId` the contract judges the unit alone, a documented
mode for a caller that does not know the outlet yet, and nothing changes for
existing consumers. `catalog.variant_is_listed_at_outlet` fails closed the
same way (false for a NULL, unknown, foreign or other-unit outlet).
The Sales order-line trigger still calls the unit-keyed
`catalog.assert_variant_listed`; moving it to the outlet-aware function is a
Sales decision.

Routes: `GET` and `PUT /catalog/outlets/{outletId}/hidden-items` with
`{ "itemIds": [...] }` (Catalog 44 to 46 operations), at most 500 items per
outlet (a restaurant outlet hides tens, a store a few hundred; the bound keeps
the set one read and one ETag), `If-Match` required (`428`/`412`), the same
gate, plan rule and closed-outlet rule as the menus of an outlet, and one
audit record per real change. A set has no repeats: the schema refuses an
item sent twice at the position of the repeat, and the use case compares,
writes and records the distinct items. The answer repeats the item reference
every other route uses (`itemId`, `code`, `name`, `status`). Whole-set `PUT` was
chosen over a `{hide, unhide}` delta: the assortment of a unit uses
`listing-changes` because its set is unbounded, while this set is bounded
and, like the menus of an outlet and the structure of a menu, is replaced
under the tag of the list that was read. OpenAPI moves to `13.3.0` (additive).
Details in `CATALOG_USE_CASES.md`, "K16 as built" (C30).

### S1 as built: the hours of the menus of an outlet (migration `0107`, baseline revision 117)

The owner decided that a business shows different menus at different times
at an outlet and that menus only organize the till screen: they never limit
what is sold, `admit` ignores them, and the catalog stays abstract with no
per-channel flag (OV18, OV24). A window decides which of the menus assigned
to an outlet (M2a) the till shows now. Holidays and date ranges are out of S1
and stay deferred.

The window lives on the assignment of the menu to the outlet, not on the
menu: the menu stays reusable and organization-wide, and the same "Breakfast"
may open 06:00 to 11:00 at one outlet and 07:00 to 12:00 at another. A window
is a set of ISO weekdays (every day when omitted) and a local `from`
(inclusive) and `until` (exclusive); `until` earlier than `from` crosses
midnight and the window belongs to the weekday it starts on. A menu with no
window is always shown.

Migration `0107` adds `catalog.outlet_menu_windows` (primary key
`(organization_id, outlet_id, menu_id, window_number)`, composite foreign key
to `outlet_menus`, number 0 to 13, the time check of
`pricing.price_rules_time_window_chk` without any foreign key to Pricing) and
`catalog.outlet_menu_window_weekdays` (one row per weekday of a window), with
the same triggers, RLS and grants as `outlet_menus`. Columns on `outlet_menus`
were rejected (a menu needs several windows and each window a repeating set of
weekdays). `organization.outlet_operating_context` returns the site time zone
as a trailing column, the smallest change that lets Catalog read the local
time without reading organization tables (`MODULE_CONTRACTS.md` §4.4).

The route is the existing `GET` and `PUT /catalog/outlets/{outletId}/menus`
(C31): `windows` is an optional list in the request, each entry naming its
`menuId`, and each menu of the answer carries its `windows`. Overlapping
windows of one menu are refused (`422 catalog.menu_windows_overlap`), not
merged; at most 14 per menu; a body without `windows` shows every menu always.
OpenAPI moves to `13.4.0` (additive).

The server evaluates the windows in SQL at read time, in the time zone of the
site of the outlet, at an instant a `Clock` gives the use case once per
request, so tests inject it. The till read keeps the menus open now, in the
outlet order, adds `menusValidUntil` (the next instant the set changes) and
its entity tag gains `-m<mask>[-u<instant>]`: the mask of the open menus by
position (exact, not a hash) and that instant, so the tag moves with the
clock without any write and never answers `304` for a stale set. The 304
costs no new statement. Performance in `PERFORMANCE_BASELINE.md` §14.11.
Details in `CATALOG_USE_CASES.md`, "C31".

## 22. Sources

* Owner's architect, *Ninaku Core — revisión de Catálogo, contratos, SQL y offline*, 2026-10-07 (commit `e0e9bdf`), findings H1–H8, §12–§14 (owner's file, not in the repository)
* Square `CatalogModifierList`: <https://developer.squareup.com/reference/square/objects/CatalogModifierList>
* Square `CatalogItemModifierListInfo`: <https://developer.squareup.com/reference/square/objects/CatalogItemModifierListInfo>
* Square `CatalogModifierOverride`: <https://developer.squareup.com/reference/square/objects/CatalogModifierOverride>
* Toast size/sequence price: <https://doc.toasttab.com/doc/platformguide/adminSizeSequencePrice.html>
* Toast pricing strategies: <https://doc.toasttab.com/doc/devguide/apiUsingPricingRulesAndPricingStrategyToCalculatePrices_V2.html>
* Toast portions: <https://doc.toasttab.com/doc/platformguide/adminPortionsOverview.html>, <https://doc.toasttab.com/openapi/menus/tag/Data-definitions/schema/Portion/>
* Oracle Simphony condiments: <https://docs.oracle.com/en/industries/food-beverage/simphony/simcg/t_condiments_assign_condiments_to_parent_classes.htm>, <https://docs.oracle.com/en/industries/food-beverage/simphony/19.5/simcg/c_condiments.htm>
* Lightspeed K-Series modifiers: <https://k-series-support.lightspeedhq.com/hc/articles/1260804656389>
* Clover modifier groups: <https://docs.clover.com/dev/docs/managing-modifier-groups-modifiers>
* Shopify 2,048 variants: <https://changelog.shopify.com/posts/we-ve-increased-the-product-variant-limit-to-2048>
* Odoo combo choices (secondary source): <https://www.cybrosys.com/odoo/odoo-books/v18/sales/combo-choices/>
* Mews per person / per time unit products: <https://help.mews.com/s/article/Configuring-the-pricing-details-of-a-product-based-on-age-category>

### K14a as built: a quantity for each preselected option (migration `0108`, baseline revision 118)

An offer preselects options; K14a lets a preselected option start with more
than one unit (two slices of cheese on the burger). The default of an option
is still stored on the offer of the item (or of a variant override), never on
the reusable option, and a default without a stated quantity counts one unit,
so every existing offer and every payload that never uses the feature is
unchanged byte for byte.

Migration `0108` adds `catalog.item_modifier_group_defaults.quantity`
(`integer NOT NULL DEFAULT 1`, check 1 to 999; every existing row becomes 1)
and creates `catalog.item_modifier_group_option_overrides` for K14b: one row
per offer and option with `max_quantity` 0 to 999, where 0 means the option is
not offered in that offer and the effective maximum will be
`LEAST(option max_quantity, override)`, so an override can never raise it. The
table is a table because an offer needs one limit per option, which no column
of the offer or of the option can hold. It follows the defaults table exactly:
composite foreign keys to the offer (`ON DELETE CASCADE`) and to the option,
scalar-subquery row level security, the three statement-level catalog version
triggers, and `SELECT, INSERT, DELETE` for the runtime (rows are replaced by
delete and insert). No application code reads or writes it in K14a; its
contract is `database/tests/contracts/catalog-offer-quantities-and-overrides-contracts.ts`.

The route is the existing `PUT /catalog/items/{itemId}/modifier-groups`: each
offered group, on the item and on a variant override, keeps `defaultOptionIds`
and gains the optional `defaultQuantities`, a map `{ "<optionId>": quantity }`
for the defaults that start above one unit (integers 1 to 999). Reads that
emit `defaultOptionIds` (the item, the offers of a group, the till list,
search and scan, and the `resolve` snapshot) emit `defaultQuantities` only
when at least one default is above one unit. OpenAPI moves to `13.5.0`
(additive).

Judgement (`judgeDefaults`, then `judgeQuantities`): the defaults are judged
first, exactly as before (`422 catalog.default_options_invalid`); then every
default quantity must not exceed the `maxQuantity` of its option and the units of all
the defaults (the quantity of each, one for a default without an entry) must
not exceed `maxSelections`; a key of `defaultQuantities` that is not a default of the same offer is a `400` at the door (the schema), the domain refuses it too; a violation of the other rules is the new
`422 catalog.default_quantities_invalid`, pointing at
`offers[i].defaultQuantities`, whose message names the three rules. A sibling
code was chosen over reusing the old one because the old message says "at most
as many options as the maximum" and would mislead a caller who sent one option
with a quantity. A quantity change alone is a change of the offer: the
change-detection key and the audit summary (`o1*3` after the option id) carry
it, so the item tag moves and a second identical request writes nothing. It is
never a shrinking edit, so it needs the plan like any change of defaults, even
when the quantity goes down (the tests are named after this decision).

`admit`: `EffectiveOffer` carries `defaultQuantities`; a default counts its
quantity, so `reducedDefaults` names a default chosen with less than its
quantity (`defaultQuantity` and `quantity` as in section 5.1), `removedDefaults`
names one left out with its quantity as `defaultQuantity`, a default that is no
longer available is never named and a preselection is never added (OV7). The
bundle components use the same function.

An option write still does not judge offers again (K5 design note): lowering
the `maxQuantity` of an option below a stored default quantity succeeds. The
till, search, scan and `resolve` read the effective quantity
`least(default quantity, option maxQuantity)` and leave it out when it falls to
one, so a till never preselects more than the option allows; the read of the
offer for the back office answers what was stored until the next offer write
judges it again.

Cost: the quantities are one more keyed probe on the defaults table per offer,
in the same statements as the ids and only through rows above one unit joined
to the option; data without quantities above one unit pays the probe only. The
till plan spec keeps its ceilings (`PERFORMANCE_BASELINE.md` §14.12 records the
buffers before and after). Proofs: `test/e2e/catalog-offer-default-quantities.e2e-spec.ts`
and the unit specs of `modifier-offer`, `admitted-offer`, the request schema and
the responses. Details in `CATALOG_USE_CASES.md`, "K14a as built" (C13).

### K14b as built: lower or withhold an option for one offer (no migration, baseline revision 118)

An offer can now lower the maximum of an option for itself, or withhold the
option altogether: a burger offers bacon at most once while the combo of the
same burger offers it up to three times, and the kids' variant does not offer
it. The limit lives on the offer (the item's or a variant override's), never on
the reusable option, so the option and every other offer of it are untouched,
and an offer without limits behaves and answers exactly as before, byte for
byte. No migration: the table `catalog.item_modifier_group_option_overrides`
that `0108` created is read and written now (one row per offer and option,
`max_quantity` 0 to 999, runtime `SELECT, INSERT, DELETE`).

The route is the existing `PUT /catalog/items/{itemId}/modifier-groups`: each
offered group, on the item and on a variant override, gains the optional
`optionLimits`, a map `{ "<optionId>": maxQuantity }` of integers 0 to 999.
The body stays strict. The effective maximum of an option in an offer is
`LEAST(option max_quantity, limit)`; a limit can never raise it, so one that is
equal to or above the maximum of its option is refused as a limit that would
raise or do nothing, with the new `422 catalog.option_limits_invalid`
pointing at `offers[i].optionLimits.<optionId>` and a message that names the
option and its real maximum (a limit on an option that is not an available
option of the group is refused with the same code). `0` means the option is
not offered in that offer.

Judgement (`judgeOne`): the limits first, then the rule, the defaults and their
quantities, all against the effective maximum. The units an offer can reach
(`reachableUnitsOf`) are the sum of the effective maximum of the available
options, so a minimum above them is `selection_rule_invalid`, as before, and a
group whose every option is limited to 0 cannot be required; a default cannot be
an option limited to 0 (`default_options_invalid`) and a default quantity cannot
exceed the limit (`default_quantities_invalid`). A limit already stored for the
same option in the same offer is not judged again: lowering an option below a
stored limit makes the limit moot (the `LEAST` wins) and is not an error, and
reading an offer and sending it back never fails because of it. The
change-detection key and the audit summary carry the limits
(`;limits=o1=1+o2=0` after the status and the defaults), so a limit-only edit
moves the item tag and is audited, and, like a quantity-only edit, it needs the
plan, lifting limits included.

Writes: the limits are replaced as a set in the same transaction as the offer,
after the defaults: the statements of the scope delete the limits of the offers
of the scope and insert the sent ones (a group that leaves the list loses its
limits). The lock order is unchanged (catalog version, then the item, with
`FOR SHARE` on the groups of `SHARE_LOCK_GROUPS`), so there is no new race: an
option written meanwhile is judged by the existing rule that option writes do
not judge offers again (K5 note), and the lost-option race maps to the same
`422` as an invalid limit.

Reads: the item read, the offers of a group (C12) and the till list and search
answer `optionLimits` only when the offer limits an option. The item and
group-offer reads answer what is stored. The till answers only the limits of
available options that really lower their option (`limit < maxQuantity`), as a
map on the offer (of the item and of a variant override), so the client takes
the lower of the `maxQuantity` listed once per group and the limit of the offer;
`0` means the option is not offered in that offer. This is the leanest correct
shape: the options of a group are shared by every item of the page and listed
once, and repeating them per offer would multiply the payload for a rare
feature. `resolve` and `admit` answer per effective offer, so there the options
are already reduced: an option limited to 0 is absent (a selection of it is
`option_not_offered`, since it also leaves the offered ids) and `maxQuantity`
is the effective maximum (more is `option_quantity_above_maximum`); an offer
left with no option is dropped. `domain/sellable/sellability.ts` keeps its
single map by option: an option belongs to one group and a variant has one
effective offer per group, so no option can sit in two offers of a line. The
Sales and Commerce triggers check only the option's own maximum; a limit is
stricter, never looser.

Sellability: `variantConfigurable`, the one fragment of the till, the search,
the scan, `resolve` and `admit`, sums `least(option max, limit of the offer)`;
a required offer the limits leave unable to reach its minimum makes the variant
unsellable everywhere with `required_modifier_unsatisfiable`, the same answer
as a lowered option.

Cost: the shared sellability fragment asks once per offer with a minimum
whether the offer has any limit (a one-row keyed probe) and only offers with
limits probe each option; the till offers and the offers of `resolve` and
`admit` ask once per statement which groups of the organization have limits
(a one-row probe on the group index per group); the item read asks once per
group of the item. Data without limits pays those probes and nothing per
option: about +6 % buffers on the till read of 2,000 items and +12 % on
`resolve` of 100 variants, against +24.5 % and +53 % for the first version
that probed per option and per offer. Putting the group set also inside the
shared fragment was cheaper (+0.44 %) but inflated the estimated cost of the
nested combo plans and made the menu plan spec unstable, so it was not kept. `PERFORMANCE_BASELINE.md` §14.13 records the numbers. OpenAPI
moves to `13.6.0` (additive). Proofs:
`test/e2e/catalog-offer-option-limits.e2e-spec.ts`, the unit specs of
`modifier-offer-limits`, `option-limits`, `offer-list-judgement`, the request
schema, the responses and the statements. Details in `CATALOG_USE_CASES.md`,
"K14b as built" (C13).

### K13 as built: structured variant dimensions (migration `0109`, baseline revision 119)

The matrix (`POST /catalog/items/{itemId}/variant-matrix`) used to write only
concatenated codes and names (`m-azul`, `M / Azul`). It now also persists the
dimensions it names, their values and the link of every variant to its values,
so "every blue variant" and "the items that have an M" are questions SQL can
answer. The four tables, their keys, grants and triggers are in
`database/README.md` (revision 119) and justified in `DECISIONS.md` D34; the
routes and payloads in `CATALOG_USE_CASES.md`, "K13 as built" and C32.

What changed from section 8:

| Design | As built |
|---|---|
| "At most 3 per item, enforced in the database (trigger)" | No trigger: `item_variant_dimensions.sort_order` is `0` to `2` and unique per item, so a fourth dimension has no free position and the check constraint and the unique key refuse it, with no read and no lock. The matrix schema also refuses more than three dimensions with a `400` |
| "A deferred constraint trigger refuses two active variants of one item with the same set of values" | Built as two triggers sharing one function: a deferred constraint trigger on the links and an immediate trigger on a variant that becomes active. The first version compared the whole item on every link row and a 500 variant item exceeded the 5 s statement timeout; it now checks once per variant (only the row of the first dimension of the variant reads, the others return) and compares only with the active variants that share that value, through `item_variant_dimension_values_value_idx`. It serializes per item with `FOR NO KEY UPDATE` on the item row, the lock the item commands already hold after the version row, so the lock order is unchanged. Variants with no links (the default variant, the ones made before dimensions) are tolerated. Completeness (a linked variant holds a value of every dimension of the item) is the application's: the matrix always links every dimension |
| Failure of the check | A deferred trigger would fail at `COMMIT`, after the use case returned. The matrix therefore runs `SET CONSTRAINTS ... IMMEDIATE` for the trigger of the links right after it writes, inside the repository. The trigger of a variant that becomes active is immediate (a plain `AFTER UPDATE OF status` row trigger), so the status `UPDATE` itself fails and the transition keeps its statement budget. Both map the `23505` of `item_variant_dimension_combination_uq` to `409 · catalog.variant_combination_taken` |
| "Find-or-create the dimensions by derived code" | The code of a dimension is its name reduced like an item code (`itemCodeFromName`, lowercase letters and digits joined by hyphens, `dimension` when nothing is left); two dimensions of one matrix that reduce to the same code are a `400`. A dimension is found by that code ignoring case in the default catalog; a value by the code the request sends (the matrix always sent value codes), ignoring case, inside its dimension. What exists keeps its stored name and its stored code case |
| "Value identity survives a rename" | The value has its own id and `code`; renaming and archiving a dimension or a value is not built (the runtime holds `SELECT, INSERT` only), so nothing can yet move or retire one |
| Re-apply links old variants | A cell whose code exists, not archived, with exactly the name, SKU and quantity policy of the cell and no links is linked; one already linked to the same values is in effect; any other case is `409 · catalog.code_taken`. A matrix that only links answers `200`, one that creates answers `201`, one with nothing to do answers `200` and writes nothing |
| Dimensions of an item are fixed | The first matrix attaches them; a later matrix names the same dimensions in the same order (it may add values) or answers `422 · catalog.variant_dimensions_locked`, whose detail names the dimensions the item has |
| Filter | `variantValue=<dimension code>:<value code>`, repeatable up to three times for different dimensions, asks for **one active variant carrying every value**. The design said "AND across dimensions"; a per-variant meaning is the one a shopper means by "blue M", and the other (an item with a blue variant and an M variant) is one request per value away. The same dimension twice is a `400`, because one variant holds one value of it |

Statement budget of the matrix: 18 for the first matrix of an item, 16 for one
that adds values, 16 for one that only links, whatever the number of cells
(`catalog-variant-matrix-statement-budget.e2e-spec.ts`). Plan evidence and the
before and after of the item read and the item list are in
`PERFORMANCE_BASELINE.md`, section 17.

Not built, in the order they will probably be needed:

1. Renaming, archiving and ordering dimensions and values (needs `UPDATE` grants and a route per use case), and a value translation table.
2. A cap on dimensions per catalog and values per dimension. The matrix itself caps three dimensions of fifty values per command, and `GET /catalog/variant-dimensions` answers every active one; no creation limit exists yet.
3. Dimension values in the till and sellable payloads (the variant code and name remain what a till sells by), and the filter in the till search.
4. `perf:http` scenarios that seed dimensions, to measure `GET /catalog/variant-dimensions` and the filter over HTTP with 1 and 16 clients.
5. Descriptive attributes that do not define a variant (material, fit) wait for a reader with a name, as section 8 already said.

### K14c as built: text options, prefixes and print hints (migration `0110`, baseline revision 120)

A modifier option can now be a text the customer types (an engraving, a cake
message) and can carry a prefix and two print hints. Everything is a column of
`catalog.modifier_options`: no table, policy, trigger or grant is added
(`DECISIONS.md` D36). The write route is the existing
`PUT /catalog/modifier-groups/{modifierGroupId}` (and the create
`POST /catalog/modifier-groups`, which shares the option schema); each option
of the set gains `kind: "text"`, `textMaxLength`, `prefix`, `printsOnReceipt`
and `printsOnKitchenTicket`. The set is a replacement, so a field left out
means its default: an option sent back without its `textMaxLength` or `prefix`
loses it, and the group read answers every field to send back.

**What Catalog enforces** (the domain judges every option of the sent set, in
`option-presentation.ts`, and the database backs each rule with a `CHECK`;
the `422 · catalog.modifier_option_presentation_invalid` names the option and
the rule at the field):

| Rule | Field | CHECK |
|---|---|---|
| `kind` is `instruction`, `catalog_variant` or `text`; a `text` option names no variant | `kind`, `variantId` | `modifier_options_kind_chk`, `modifier_options_kind_target_chk` |
| `textMaxLength` is a whole number from 1 to 500 for a `text` option and absent for any other kind | `textMaxLength` | `modifier_options_text_max_length_chk` |
| A `text` option holds exactly one unit (`maxQuantity` 1) and no prefix or `add` | `maxQuantity`, `prefix` | `modifier_options_text_shape_chk` |
| `prefix` is one of `add`, `extra`, `light`, `no`, `on_the_side`, `substitute` | `prefix` | `modifier_options_prefix_chk` |
| `light`, `no`, `on_the_side` and `substitute` need `maxQuantity` 1 | `maxQuantity` | `modifier_options_prefix_quantity_chk` |
| `no`, `on_the_side` and `substitute` apply to `instruction` options only | `prefix` | `modifier_options_prefix_kind_chk` |

**What Catalog does not enforce.** `extra` accepts any `maxQuantity`; "extra
as a `scale` effect needs quantity 1" (X11) is Production's rule on its own
effect, refused when the sale quantity is not 1, and Catalog does not read
Production (OV18). The prefix never changes a price, a stock movement or a
sale. The print hints are hints: Catalog does not print and no sale rule
reads them; both default to `true`, there is no per-channel flag (OV18, OV24).
The kind of an option never changes (`catalog.modifier_option_target_fixed`),
so an instruction option cannot become text.

Per K5, an option write does not judge the offers that already hold the option:
a prefix that needs `maxQuantity` 1 is judged when the option is written, and
the database refuses the state anyway.

Reads: the group read, the group write answer and the item read carry the new
fields on an option **only when they differ from the default** (`kind` is
always there; `textMaxLength` and `prefix` when set; `printsOnReceipt` and
`printsOnKitchenTicket` only as `false`), so every payload and tag of existing
data is byte-identical. The till, the search, the scan, `resolve` and `admit`
are K14d. **Sales must add a `text` branch to its kind check (X10) and a
column for the typed value (X3) before it consumes text options**; until K14d
no reader outside the group and the item read can see a text option.

### K14d as built: the reads and the judging of text selections (no migration, baseline revision 120)

**Reads.** The till read (`GET /catalog/outlets/{outletId}/sellable-items`,
its `modifierGroups[].options[]`), the snapshot of `resolve` and `admit`
(`offers[].options[]`) and, since K14c, the item read answer an option's
`textMaxLength`, `prefix`, `printsOnReceipt: false` and
`printsOnKitchenTicket: false` **only when they differ from the default**
(`kind` is always there, so a text option says `kind: "text"`). Existing data
therefore answers the same bytes, tags and golden files as before. The search
and the scan carry no option, so they are unchanged. The four columns join the
`SELECT` that already reads the option (the `available_options` CTE of the till
and the option subquery of the snapshot), so no statement and no probe is
added; the plan specs of the till, the search and the sellable facts pass with
their ceilings untouched. The database hands the presentation of an option as
one stripped JSON object (`presentation`) and the repository flattens it, so a
default option has no extra key and no `null`.

**The selection.** `OptionSelection` gains an optional `text`; `AdmittedSelection`
carries it back. All of it is additive and optional in `CatalogSellableContract`,
and a consumer that never sends `text` sees no difference. Before any lock, the
request normalizes every `text` to NFC (a composed and a decomposed é are one
text, and the length is counted after normalizing, in characters and not code
units) and refuses a line with more than 10 selections carrying text, those of
the components of a combo included, with the request problem `too_many_texts`.
After the sellable verdict, per selection and after the option is found and its
quantity is within its maximum, the domain judges the text with the same
refusal list as any other selection (`optionId` names the option):

| Reason | When |
|---|---|
| `text_required` | the option is of kind `text` and the selection carries no `text` |
| `text_not_allowed` | the option is not of kind `text` and the selection carries `text` |
| `text_invalid` | the text is blank (only white space) or holds NUL or any control character, a line break and a tab included |
| `text_too_long` | the text has more characters than `textMaxLength`, which never exceeds 500 |

A text option is held to one unit by its maximum, so a selection of two is
`option_quantity_above_maximum` before its text is judged. Sellability needed
no change: a text option has no target variant, so it is available while it is
active, and a required group whose only option is text is satisfiable with
quantity 1 (proved by `catalog-text-selections.e2e-spec.ts`). Defaults are not
refused for text options in this slice: a text option preselected on an offer
would have no value to preselect, and `admit` never adds a default (OV7); the
offer writer does not forbid it yet.

**What stays outside.** Catalog does not store the text: Sales will. Sales must
add the `text` branch to its kind check (X10) and a column for the value (X3)
before it consumes text options; until then nothing consumes `text`. The
refusal details a combo component answers (`bundle-refusal-detail.ts`) have a
sentence for each new reason.

### K12b as built: device bootstrap (no migration, baseline revision 124)

Decision `DECISIONS.md` D41; routes in `CATALOG_USE_CASES.md`, "K12b as built". The Sync module does not exist, so the bootstrap is a Catalog read API (owner decision); what Sync must add is listed below.

| Design (§7.2, §7.4) | As built |
|---|---|
| Read at one `catalogVersion`: a `REPEATABLE READ` snapshot per page set, or pages pinned to a version and restarted only for the pages a later delta touches | **Pages pinned to a cut, restarted when the cut moves.** Each request runs in its own `REPEATABLE READ` read-only snapshot (`runReadOnlySnapshot`), so permission, cut check and data are coherent; across requests the pin is an equality check of `(catalog version, structure version, unit)` against the `cut` the opening call answered, made in the same snapshot as the page. If every page of a walk saw the same cut, no catalog or structure write committed between the first and the last read, so the pages are exactly the catalog at that version. *Rejected, with evidence:* a snapshot per page set cannot span HTTP requests (a PostgreSQL snapshot lives in one transaction on one pooled connection; holding it for a whole download pins a connection and the vacuum horizon per device), and "restart only the pages a delta touched" needs the rows as they were at the cut, which rows updated in place do not keep. *Cost:* a write during a download restarts it; with pages of 100, 3,000 items are 30 pages |
| Unlimited items in pages | Keyset on `(name_search_key, id)`, 1 to 100 per page (the HTTP contract), no ceiling; the same sellable rule as the till reads (OV24: the unit decides, the outlet only hides) |
| SKU and the barcode index of what the outlet sells | `sku` and the active `barcodes[]` on each variant of a sold item; the device builds `barcode -> variant` locally. An archived barcode, an inactive variant and an item the outlet does not sell are never in it |
| Deltas applied in version order, idempotent by event id, no gap and no duplicate | The cut carries the catalog version `V`; the device applies the feed events with `catalogVersion > V`, in publication order, and skips the rest. A retry of a page is a `GET` with no effect (same cut and cursor, same page); a retried event is idempotent because a `retired` change deletes by id and an `upserted` change rereads by id |
| Tombstones | The bootstrap holds only live data. A `retired` or `removed` change deletes what its id names: an item event the item with its variants and barcodes; a variant event the variant with its barcodes; a barcode event the barcode |
| Brands and variant dimensions | **Not in the bootstrap.** They move the catalog version and write no event (K12a). No till screen and no till read shows them, so including them would leave devices stale without anything announcing it; excluding them is the sound option. The slice that shows them on a till adds them to the bootstrap and maps their tables to events together |
| Permission change or revocation | Each call decides `catalog.sellables.view` at the outlet again: a revoked permission or a scope moved to another outlet stops the next page with the plain `403`. What the device already holds is not erased by the API: invalidating a device grant is Sync (next session) and K12c |
| Switching business unit | The cut names the unit; the cut of another unit is refused with `409`, the data of two units never mix, and a cursor is bound to the cut, the outlet and the organization |

**What Sync must add.** (1) A delta read: the `catalog.*` events of the organization after a version or a publication position, which `catalogVersion` already orders. (2) A reread by id of one aggregate (an item with its variants and barcodes, resolved from an item, variant, barcode or offer id; the reference data again for a modifier or category event). (3) The device grant and its revocation. (4) The rule for what a device does with local sales when its grant is revoked (K12c). The feed's ids are enough to delete; they are not enough to reread a new variant or barcode whose item the device does not know, which is why the reread belongs to the server.

### K12a as built: catalog change feed (migration `0114`, baseline revision 124)

Decision `DECISIONS.md` D40; no route and no OpenAPI change (the feed is
written, not served: serving it to a device is K12b and the Sync module).

| Design (§7.2) | As built |
|---|---|
| Every catalog command writes typed events in its transaction | Not in each command: statement-level triggers `<table>_change_feed_{insert,update,delete}_trg` on 28 tables call `catalog.record_change_feed()`. They sort after the table's version trigger, read the version the statement just produced and write one event per changed aggregate through `events.append_tenant_event` (the function Events publishes, `MODULE_CONTRACTS.md` §4.5). `catalog.record_change_feed()` is `SECURITY DEFINER`, so the runtime role needs no privilege on `events`. Catalog writes no `events` table (OV18) and a command cannot forget to announce a write |
| `item`, `variant`, `barcode`, `offer`, `listing` | Nine types, contract version 1: those five and `catalog.modifier.changed`, `catalog.bundle.changed`, `catalog.menu.changed` and `catalog.category.changed`, because the till reads categories, modifier groups and options, combos and menus (C17) and a device that cannot learn that an option was renamed cannot apply the delta. The mapping is the table below |
| Payload with `catalogVersion`, ids and a tombstone `{ id, retiredAt }` | `{ schemaVersion: 1, catalogVersion, change, <id>, status?, retiredAt? }`. `change` is `upserted`, `retired` (the aggregate's status became `archived` or `retired`) or `removed` (the row was deleted); `retiredAt` (UTC, microseconds, `Z`) is present exactly when `change` is not `upserted`; `status` is present when the event comes from the aggregate's own row (an `inactive` item is an upsert that says `inactive`). No names, prices, barcode values or secrets: the device rereads by id. The ids are `itemId`, `variantId`, `barcodeId`, `offerId`, `modifierGroupId`, `bundleGroupId`, `menuId`, `categoryId` and, for a listing, one of `{ catalogId }`, `{ businessUnitId, catalogId }`, `{ businessUnitId, itemId }`, `{ outletId, itemId }`, `{ outletId, menuId }`. The executable contract is `src/modules/catalog/contracts/catalog-change-event.ts` |
| The version assigned and the event written under the same lock | A command takes the version row `FOR UPDATE` first (the existing lock order); the bump trigger updates it; the feed trigger reads it and appends the event, then Events takes the publication counter of the organization. A reader that sees `catalogVersion = N` committed sees every event of version at most N, and the publication position follows the version because both are taken in that order. The lock order is now: catalog version row, then the item and the rest of the catalog, then the publication counter (`DECISIONS.md` D40: another module that emits events must do so after `admit`) |
| An event per aggregate, not per command | One event per distinct aggregate per statement, at the version that statement produced; a command of several statements announces each version it produced, so, for a command that writes only the mapped tables, the versions of its events cover its whole range |
| Idempotent retry | A retried create returns the stored answer and runs no write, so it announces nothing (`Idempotent-Replayed: true`); inside Events the idempotency key `catalog-change:<version>:<event>:<md5 of the payload>` makes the same event append once |
| Version triggers on every table a device reads | Checked by a query, not by memory: every table that `till-read`, `till-menus`, `till-scopes`, `till-bundle-groups` and the sellable reads touch already carried the three triggers (menus, bundles and `item_assets` by `0101`, `0100` and `0095`). 31 tables carry them; 28 write events. The 17 tables without version triggers (`allergens`, `allergen_translations`, `dietary_tags`, `dietary_tag_translations`, `item_allergens`, `item_dietary_tags`, `item_nutrition_profiles`, `item_nutrition_facts` and the nine other `*_translations`) are read and written by no use case: they get their triggers and their mapping with C23 (translations) and C24 (food data) |

Mapping (`root` rows carry `status` and can retire; `child` rows always report their root as upserted):

| Event | Root | Children |
|---|---|---|
| `catalog.item.changed` | `items` | `item_categories`, `item_assets`, `item_regulatory_flags`, `item_variant_dimensions` |
| `catalog.variant.changed` | `item_variants` | `item_variant_identifiers`, `item_variant_dimension_values` |
| `catalog.barcode.changed` | `item_variant_barcodes` | |
| `catalog.offer.changed` | `item_modifier_groups` | `item_modifier_group_defaults`, `item_modifier_group_option_overrides` |
| `catalog.modifier.changed` | `modifier_groups` | `modifier_options` |
| `catalog.bundle.changed` | `bundle_groups` | `bundle_group_items` |
| `catalog.menu.changed` | `menus` | `menu_sections`, `menu_entries`, `menu_entry_variants` |
| `catalog.category.changed` | `categories` | |
| `catalog.listing.changed` | `catalogs`, `business_unit_catalogs`, `business_unit_items` | `outlet_hidden_items`, `outlet_menus`, `outlet_menu_windows`, `outlet_menu_window_weekdays` |

`product_brands`, `variant_dimensions` and `variant_dimension_values` move the
version and write no event: no till read uses them yet. K12b decides whether
the bootstrap carries brand and dimension labels, and maps them then.

**What the feed does not promise.**

* A write outside a tenant context (a migration, an administrative tool) moves
  the version and writes no event, because Events requires the tenant. A device
  whose version is behind a version with no event must bootstrap again; a
  migration that changes catalog data says so.
* An update that changes no column still bumps the version and announces its
  rows: the version triggers never compared values, and the feed follows them.
* One event per aggregate per statement: a statement over 500 rows writes 500
  events, and a command of ten statements writes at least ten.
* `catalog.listing.changed` for a `business_unit_items` or `outlet_hidden_items`
  row names the item, not the variants it covers; the device rereads the item.

**Evidence.**

| Claim | Proof |
|---|---|
| Statement order, version per event, tombstones, children, no tenant context, rollback, other tenants, immediate constraints, the event function's refusals, no direct access to `events` | `database/tests/contracts/catalog-change-feed-contracts.ts` (`NINAKU_CATALOG_CHANGE_FEED_OK`) |
| Every table that bumps the version writes events or is on the exclusion list; the feed trigger fires after the version trigger; the tables without version triggers are exactly the pending ones | the same contract |
| Two commands that lock the version row: distinct increasing versions, events in the same order, the second waits | `database/tests/concurrency/catalog-change-feed-concurrency.ts` |
| Over HTTP: create, archive and restore, offers and their tombstone, modifier groups, idempotent replay, four parallel creates, a business unit registered (a deferred trigger that writes at commit) | `test/e2e/catalog-change-feed.e2e-spec.ts` |
| A command that fails after its write leaves no event, no outbox row and no version move | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `leaves the offers, their ids, the audit trail, the announced changes and the version untouched when the record fails after the write` |
| The payload contract | `src/modules/catalog/contracts/catalog-change-event.spec.ts` |
| The runtime role has no privilege on the `events` schema, its tables or the function (the trigger is `SECURITY DEFINER`) | `database/tests/prepare/prepare-database-contracts.ts` (`NINAKU_PREPARE_RUNTIME_PRIVILEGES_OK`) and the contract above |

**For K12b.** The feed gives a device the ids that changed after a version; it
does not serve them. K12b needs: a read of the events after a position or
version for a device (Sync owns the cursor and the acknowledgements); the
bootstrap at one `catalogVersion` with SKU and barcode index; whether brands and
dimensions are part of it; and the rule for a device whose version is behind a
gap with no event (bootstrap again).

### K15d as built: variant identifiers (migration `0113`, baseline revision 123)

Decision `DECISIONS.md` D39; routes in `CATALOG_USE_CASES.md`, "K15d as built".

| Design (section 17) | As built |
|---|---|
| `item_variant_identifiers (item_variant_id, scheme, value)`, scheme `mpn`, `isbn` or `manufacturer_code`, unique per variant | As designed, with the denormalized `item_id`, `organization_id` and `catalog_id` of the K13 tables, an index by item for one probe per item, and the primary key `(item_variant_id, scheme)`; `SELECT, INSERT, DELETE` for the runtime |
| Value bounded and NUL-free, not unique across variants | `varchar(100)`, not blank; the schema trims it and rejects NUL (PostgreSQL cannot store it); two variants may hold the same value |
| Set inside the existing variant write | `identifiers` (optional) on `defaultVariant` of the item create, on the variant add and on the variant edit, where it replaces the set and an empty list clears it; the variant matrix does not take it |
| Shown in the item read | `identifiers` on a variant only while it has any, so every existing payload, tag and golden file is byte-identical. Channel identifiers stay out |

Not built: lookup by identifier, GTIN checks (barcodes keep them) and identifiers on the matrix.

### K15c as built: product brands (migration `0112`, baseline revision 122)

Decision `DECISIONS.md` D38; routes in `CATALOG_USE_CASES.md`, "K15c as built".

| Design (section 17) | As built |
|---|---|
| `catalog.product_brands`, not the `organization.brands` of Organization | Table with a unique case-insensitive name per catalog, `active`/`archived` status, idempotent creation and a composite key; `items.product_brand_id` is a nullable composite foreign key and Catalog never references `organization.brands` |
| List, create, rename, archive | `GET`/`POST /catalog/product-brands`, `GET`/`PATCH /catalog/product-brands/{id}` (renaming needs the strong tag) and `POST /catalog/product-brands/{id}/transitions` (archive, restore) |
| Item side | `productBrandId` on the item create and edit (`null` clears it); the item read answers `productBrand { id, name }` only when set; `GET /catalog/items?productBrandId=` is a candidate filter, part of the cursor binding |
| Archiving a brand that active items use | Allowed: the item keeps its brand and stays listed under it; only giving an archived brand to an item is refused (`422 · catalog.product_brand_archived`) |

Not built: a brand on a variant, merging brands, and brand logos.

### K15a as built: master data hardening (migration `0111`, baseline revision 121)

One expand-only migration, four hardenings, no new route. The decision for the
flags table is `DECISIONS.md` D37; the routes and payloads are in
`CATALOG_USE_CASES.md`, "K15a as built".

| Design (section 17) | As built |
|---|---|
| Description bounds on items, categories and modifier groups, `NOT VALID` then `VALIDATE` after proving no row breaks them | `catalog_items_description_chk`, `categories_description_chk` and `modifier_groups_description_chk`: `description IS NULL OR char_length(description) <= 2000`, each added `NOT VALID`. The routes stop at 2000 characters (`ITEM_DESCRIPTION_MAX_LENGTH`, `CATEGORY_DESCRIPTION_MAX_LENGTH`, shared by the modifier groups), but an imported or hand-loaded row is not proved, so the migration validates the constraint only when an audit query finds no breaking row (a `DO` block); otherwise it keeps it `NOT VALID`, raises a `WARNING` with the count and changes no data |
| `CHECK` that the minimum and the maximum are multiples of the step | `item_variants_sales_quantity_step_chk`: `minimum_sales_quantity % sales_quantity_step = 0` and, when there is a maximum, `maximum_sales_quantity % sales_quantity_step = 0`. Unlike the descriptions this one cannot be proved against stored data: the API accepted any policy that satisfied `item_variants_sales_quantity_policy_chk`, so a stored variant with a minimum of 0.1 and a step of 0.25 would fail the `VALIDATE`. The migration therefore audits first: with no such row it validates, with one it keeps the constraint `NOT VALID` (new writes are checked) and warns with the count, and the owner of the data corrects the rows (`SELECT id FROM catalog.item_variants WHERE minimum_sales_quantity % sales_quantity_step <> 0 OR maximum_sales_quantity % sales_quantity_step <> 0`); an unrelated update of such a row is refused until it is corrected. Note the domain rule `fitsSalesPolicy` measures the grid from the minimum, so a minimum off the step was a legal, if odd, policy before; the check makes the grid start at zero. A violation answers `422 · catalog.quantity_policy_invalid` |
| `net_content_quantity`, `net_content_unit_code` on the variant, both or neither, the unit validated by the application, no foreign key | `numeric(20,6)` and `varchar(30)`, `item_variants_net_content_chk` (both null, or a positive quantity with a non-blank unit). The unit is checked against `reference.units_of_measure` by one statement that runs only when a request sends a unit (`422 · catalog.unit_unknown`, the code of an unknown sales unit, so no new code for it); a pair with one half is `400` at the schema and `422 · catalog.net_content_invalid` if it ever reaches the database. Written with the create of an item (`defaultVariant`), the add of a variant and the edit of a variant (`null` in both clears it); the matrix does not carry it. Answered on the variant only while set; the audit of a variant edit names `catalog.item.variant.net_content_quantity` and `catalog.item.variant.net_content_unit`. The price per unit stays Pricing's |
| `item_regulatory_flags (item_id, flag)` with the five flags, RLS, version triggers, `GRANT SELECT, INSERT, DELETE` | As designed, with the composite foreign key `(item_id, catalog_id, organization_id)` of the K13 tables. Written with the create of an item and by `PATCH /catalog/items/{itemId}` with `regulatoryFlags` (the whole set, an empty list clears it; each flag once, kept in the order of the vocabulary); the item read answers `regulatoryFlags` only on an item that has any. One audit record per edit, `catalog.item.updated`, with `catalog.item.regulatory_flags` before and after next to the profile changes. Catalog declares and enforces nothing |
| Drop `catalog_items_organization_status_idx` only after `EXPLAIN` proves it unused | Decided at the end of the delivery from the plans; see `PERFORMANCE_BASELINE.md` |

Not built: a route to list items by flag (no reader needs it yet), a flag on a
variant, and any enforcement at sale time, which belongs to Sales or a
compliance module reading the flag through a contract.
