---
url: /reference/CATALOG_USE_CASES.md
---
# Catalog Use Cases

The Catalog is the first slice of roadmap Phase 6, "Shared commercial core"
(`docs/reference/ROADMAP.md`, Phase 6: Catalog, Pricing, Sales, Settlement,
Payments, Cash, Inventory, Procurement). This catalog starts from what a
member of a business tries to do with the things it sells, and derives the
minimal API from that, never from the tables. It follows the structure of
[`ORGANIZATION_USE_CASES.md`](ORGANIZATION_USE_CASES.md): numbered cases,
routes, statuses, codes and the scenario rows that would prove them. Slice
K1, the SQL foundations (migration `0075`), slice K2, the module and the
items with their default variant (C3, C4, C5, C7, migration `0076`), and
slice K3, the reads and the categories (C1, C2, C8, C9, C10, migration
`0077`), and slice K5, the modifier groups and the groups an item offers
(C12, C13, no migration), and slice K4, the variants, the size by colour
matrix, the barcodes and the barcode lookup (C6, C11, C18, migration
`0079`), and slice K6, the business-unit assortment (C14, C15, C16, no
migration), and slice K8, the in-process `CatalogSellableContract` (C20, no
migration), are built: see "K1 as built", "K2 as built", "K3 as built", "K5
as built", "K4 as built", "Hardening round as built" (migration `0080`),
"K6 as built", "K7 as built" and "K8 as built" in section 9. So are the
option offers of K9 (K9a to K9h, [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md)
§4 and §5), the SQL ownership of K10 (K10a to K10c, the same file §6), what a
new business unit sells (D1, "D1 as built"), the item and variant images
(AC1 to AC5, built by the Assets slices, `ASSETS_USE_CASES.md`), the combo
composition and admission (B1 and B2, "B1 as built", "B2 as built") and the
structure of a menu (M1, "M1 as built"), the menus an outlet shows (M2a, M2b) and the items an outlet does not sell (K16, "K16 as built"). See
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) for the tests that prove
them, and `CATALOG_OPTIONS_V2.md` §10.1 for what is left. Everything else is
frozen intent, not shipped behavior.

**Ground truth.** `database/canonical/0080_catalog.sql` (read from `staging`;
line numbers below refer to that file), its neighbours `0090_pricing.sql`,
`0100_commerce.sql`, `0110_sales.sql`, `0140_operations.sql`,
`0150_kitchen.sql`, `0170_inventory.sql`, `0260_tax.sql`;
`database/access/runtime_grants.sql`; migrations `0050`, `0055`, `0071`,
`0072`, `0073`; `docs/reference/ROADMAP.md`, `ARCHITECTURE.md`,
`DOMAIN_MAP.md`, `DECISIONS.md` (D01, D04, D07, D10, D18, D21, D24, D25,
D26, D30), `APP_SHELL_AND_ONBOARDING.md`, `AVAILABILITY_AND_PERFORMANCE.md`,
`BUSINESS_DESIGN.md`, `CURRENCY_AND_LANGUAGE.md`;
`docs/contracts/MODULE_CONTRACTS.md`, `API_AND_SCHEMA_DISCIPLINE.md`,
`FOUNDATION_HTTP_AND_OBSERVABILITY.md`; and the contracts already in `src/`
(`PermissionCheckContract`, `OperationAuthorizationContract`,
`operation-refusal.ts`, `TenantAuditContract`).

**Status words used below.** *Decided* means this document takes the
decision and the owner only needs to object. *Open* means the business owner
must answer; none is open since the owner approved OD1 to OD9 on 2026-10-04
(section 10). *Assumption* means a fact this document could not
verify from the repository.

***

## 1. Purpose and scope

### 1.1 What the Catalog is for

The Catalog answers one question for every other commercial module: **what
can this business sell, under which identity, and how is it described and
configured?** It is the source of truth for the commercial identity of a
sellable thing. It is not its price, its stock, its recipe, its tax class or
whether the kitchen ran out of it tonight (`0080_catalog.sql:8-31` states
that boundary; `ARCHITECTURE.md` §3 states "Catálogo no es Inventario.
Catálogo no es Producción.").

| Business | What it needs from a catalog on day one | What it does NOT need from the catalog |
|---|---|---|
| Small restaurant, café, bar (`BUSINESS_DESIGN.md` line 9: "producto, venta, cobro e impresión desde teléfono") | Dishes and drinks grouped by category; sizes (small/large) as variants; modifiers ("no onion", "extra cheese", "cooking point") with required/optional rules; a fast POS list for the waiter and the cashier | Prices (Pricing), kitchen stations and courses (Kitchen owns `kitchen.course_assignment_rules`, `0150_kitchen.sql:570`), recipes (Production), "86" sold-out (Operations owns `operations.item_variant_unavailabilities`, `0140_operations.sql:4598`) |
| Retail shop | Products with variants (size, colour), SKU, one or more barcodes per variant, sales unit (unit, kg) and quantity step; fast barcode lookup at the till; search by name/SKU | Stock balances and stock links (Inventory owns `inventory.catalog_stock_links`, `0170_inventory.sql:410`), packaging conversions, purchase data |
| Hotel (hospitality) | Sellable services and extras (minibar, laundry, spa, room service dishes through a restaurant business unit) | Rooms, room types and their availability: Phase 10 gives them their own hospitality aggregates (`ROADMAP.md` Phase 10). A room night is **not** a catalog item |

### 1.2 In scope for the first delivery

*Decided.* The first delivery is the smallest catalog that lets a restaurant
and a retail shop configure what they sell and lets a POS read it fast:

1. The organization's **default catalog** (`catalog.catalogs`, created lazily
   by `catalog.ensure_default_catalog`, `0080_catalog.sql:4245`).
2. **Items** with their mandatory **default variant**, and optional named
   **variants** with SKU and sales-quantity policy.
3. **Categories** (a shallow tree) and the item-to-category classification
   with one primary category.
4. **Barcodes** per variant (retail).
5. **Modifier groups and options** and their attachment to items with
   min/max selections (restaurant; usable by retail too).
6. **Business-unit assortment**: which items a business unit sells
   (`catalog.business_unit_catalogs`, `catalog.business_unit_items`,
   `0080_catalog.sql:4433`, `:4446`).
7. The **reads** behind the screens: the back-office workspace, list and
   search with keyset pagination, item detail, and the **outlet sellable
   read** used by the POS, plus a barcode lookup and a paged search of what
   the outlet sells.
8. One **in-process read contract** that Pricing and Sales will consume to
   resolve and admit a sellable variant.

### 1.3 Explicitly out of scope, with the reason

| Out of scope | Reason | Where it goes |
|---|---|---|
| Prices, price books, modifier price deltas, discounts | Owned by `pricing` (`0080_catalog.sql:24-25`; `0090_pricing.sql` references `catalog.items`, `item_variants`, `modifier_options`, `menus`) | Pricing, next Phase 6 slice. The POS read will compose catalog + pricing then |
| Taxes | `tax.catalog_item_tax_class_assignments` (`0260_tax.sql:646`) is owned by `tax` | Phase 7 |
| Stock, stock links, units conversion | Owned by `inventory` (`0170_inventory.sql:410`) | Inventory slice |
| Recipes / bill of materials | Owned by `production` (`0080_catalog.sql:27`); nothing in the catalog tables requires a recipe | Later, optional (D01: not a requirement of every tenant) |
| Kitchen course and prep station | Owned by `kitchen` (`0150_kitchen.sql:570`, `:907`) | Phase 8 restaurant vertical |
| Sold-out / 86 / runtime availability | Owned by `operations` (`0140_operations.sql:4598`) | Restaurant vertical |
| Images and media (`catalog.item_assets`, `0080_catalog.sql:623`) | No longer out of scope: built once the `assets` module existed | `PUT /catalog/items/{id}/images`, `PUT /catalog/item-images` (§5.10) |
| Translations (`*_translations` tables) | No tenant serves a second content locale yet; `CURRENCY_AND_LANGUAGE.md` ("Qué no existe todavía") states no route reads `Accept-Language`. Base text is authored in the organization default locale (`0080_catalog.sql:46-49`) | Later slice, together with content-locale negotiation |
| Allergens, dietary tags, nutrition | `DOMAIN_MAP.md` line 38 and line 78 propose moving food declarations to a separate `catalog_food` boundary "with evidence"; building on the current shape first would freeze it | After the owner confirms the food-content need |
| Generic tags (`tag_groups`, `tags`, `item_tags`, `menu_tags`) | No use case named them (see §3) | Dropped by migration `0075` |
| Bundles / combos | Built: the composition (B1), the admission (B2) and the combo groups in the till and the search (B2c); a combo price needs Pricing | Pricing and Sales (B3) follow |
| Menu time windows (S1) | Built: the hours in which an outlet shows each of its menus (C31, "S1 as built"); holidays and date ranges stay deferred | The menus an outlet shows and their order (M2a, C29), the till read by menu (M2b) and now their hours (S1); the assignment gap G1 and risk R13 are closed |
| Multiple catalogs per organization (`access_mode = 'assigned'`) | One default catalog covers the single-business and the multi-unit "shared base catalog" case (`ARCHITECTURE.md` §14) | Later, behind its own capability (OD4, decided: one catalog now) |
| Imports (CSV, spreadsheet) | A bulk path needs its own limits, idempotency and error reporting; it is a separate use case | Later |
| Offline replay of catalog writes | Catalog editing is back-office, online; D06 applies to Sales commands, not to catalog configuration | Not planned |

***

## 2. Ownership and boundaries

### 2.1 Facts the Catalog owns

Only the `catalog` module mutates these facts (one fact, one owner,
`ARCHITECTURE.md` §3):

* the commercial identity of an item and its variants: code, name,
  description, lifecycle status, SKU, sales unit and quantity policy, which
  variant is the default (`0080_catalog.sql:33-45`);
* the scannable identities of a variant (barcodes);
* the classification of items (categories and item-category links);
* the reusable modifier groups and options, and which groups an item offers
  with which min/max rules;
* the catalog namespaces and which business units list which items
  (assortment);
* later: bundle composition, menus and their structure, translations, food
  declarations.

### 2.2 Neighbours

| Neighbour | Direction | What crosses the boundary | Mechanism |
|---|---|---|---|
| Organization | Catalog reads | Business unit exists and is active; the effective business unit of an outlet on the site's date (`organization.effective_business_unit`, granted to runtime in `runtime_grants.sql`) | SQL read inside catalog repositories under RLS, the same way `organization` already reads `platform.business_unit_verticals` directly (O11 "As built"). Module-boundary rules constrain TypeScript imports, not SQL (O11) |
| Identity | Catalog consumes | Permission decision at an anchor | `PermissionCheckContract.decide` with `anchor` (`src/modules/identity/contracts/permission-check.contract.ts`) |
| Platform | Catalog consumes | Permission + capability + place operability decision | `OperationAuthorizationContract` (`platform.authorize_operation`, `0071`; `platform.authorize_operation_at`, `0073:162`) |
| Audit | Catalog consumes | Tenant audit record in the same transaction | `TenantAuditContract` |
| Pricing (future) | Reads Catalog | Sellable variant snapshot; validity of variant / modifier option ids | New `CatalogSellableContract` (C20) |
| Sales (future) | Reads Catalog | Admission of a variant for a business unit at sale time, atomically | `CatalogSellableContract.admit` inside the shared tenant transaction (D18) |
| Inventory, Tax, Kitchen, Commerce, Production (future) | Reference `catalog.item_variants` by FK | Variant identity only | FKs already in canonical SQL (§2.4) |
| Session context (Identity) | Context for the client | `workspace.businessUnits` with their verticals, outlets | Read only. "La lista `workspace.businessUnits` es contexto, no autoridad" (`APP_SHELL_AND_ONBOARDING.md` line 60): every catalog command re-checks permission |

### 2.3 Module contracts (R1 to R9)

*Decided.* The module lives at `src/modules/catalog/` with the standard
layers (`ARCHITECTURE.md` §6). Applying `MODULE_CONTRACTS.md` §10:

| Rule | Consequence for Catalog |
|---|---|
| R1 | Catalog imports only `identity/contracts`, `platform/contracts`, `audit/contracts`. Pricing and Sales will import only `catalog/contracts` |
| R2, R6, R7 | Use cases open the transaction with `TenantTransactionRunnerService`; repositories in `infrastructure/` run SQL; controllers never touch `TransactionContext` beyond passing it |
| R3 | Domain rules (default-variant invariant, min/max selection validation, code normalisation, category depth) are pure TypeScript, no Nest, no Zod |
| R4, R5, R8 | Catalog never imports `src/verticals/**`. Vertical-specific behavior (restaurant modifiers UX, retail barcode-first UX) is the client's concern, not a server `if (vertical === ...)` (`ARCHITECTURE.md` §14) |
| R9 | No system transaction: every catalog path runs with tenant context |

Published contract (section 15 of `MODULE_CONTRACTS.md` must list it in the
same change that publishes it): `CatalogSellableContract`
(`src/modules/catalog/contracts/catalog-sellable.contract.ts`), with
`resolve` (read) and `admit` (lock-and-check) methods (C20).

### 2.4 Schema boundary and existing cross-schema SQL

The `catalog` schema is owned by `0080_catalog.sql` (D02). Today:

* **The API could not reach it before K1.** `runtime_grants.sql` granted
  `USAGE` only on `reference, identity, organization, platform` and nothing
  on `catalog`; K1 (migration `0075`) adds the grants listed in "K1 as built"
  (section 9).
* **Catalog reads Organization** in SQL: FKs to `organization.organizations`
  and `organization.business_units`; `guard_assortment_change`,
  `assert_variant_listed` and `resolve_barcode` lock or read
  `organization.business_units` (`0080_catalog.sql:4460`, `:4504`, `:4528`).
* **Thirteen schemas reference catalog tables by FK** (counted with `git grep
  'REFERENCES catalog\.'`): pricing (items, variants, categories, menus,
  modifier groups and options, bundle items, `catalogs`), commerce (cart and
  storefront publication), sales (variants, modifier options, bundle items,
  menu entries, allergens), operations (variants), kitchen (items, variants,
  categories, menu sections), inventory, production, crm, loyalty, tax,
  invoicing, integrations, analytics. `catalog.item_variants` is the hub:
  **variant identity is the most load-bearing fact of this module** and its
  ids must never be reused or hard-deleted.

***

## 3. Table traceability

Classification: **USE NOW** (the first delivery writes or reads it), **USE
LATER** (justified by a named later use case), **DROP OR RESHAPE** (no
defensible use case in its current shape, `API_AND_SCHEMA_DISCIPLINE.md` §3
and §6.4; `ROADMAP.md` §1.3: "Tables with no defensible purpose should be
reviewed instead of automatically exposed").

### 3.1 Existing tables

| Table (line) | Class | Reason |
|---|---|---|
| `catalogs` (4229) | USE NOW | Every master row carries `catalog_id NOT NULL` (`assign_catalog_id`, 4372); the default catalog is created lazily. Only the default catalog is used in v1 |
| `items` (76) | USE NOW | The commercial item (C3–C7) |
| `item_variants` (140) | USE NOW | Default variant is mandatory (`catalog_items_require_default_variant_trg`, deferred, 2979); named variants for sizes/colours; FK hub for every downstream module |
| `item_variant_barcodes` (269) | USE NOW | Retail scan (C11, C18); `resolve_barcode` (4528) already exists |
| `categories` (354) | USE NOW | Back-office organisation and POS grouping (C9, C10, C17); cycle prevention with an advisory lock (`lock_hierarchy`, 2999) |
| `item_categories` (430) | USE NOW | Classification with one primary (C8); a pure link table with a `FOR ALL` policy (3980), so it can be replaced as a set |
| `modifier_groups` (1672), `modifier_options` (1776) | USE NOW | Restaurant customisation (C12) |
| `item_modifier_groups` (1937) | USE NOW | Which groups an item offers, with `min_selections`/`max_selections` per item (C13) |
| `business_unit_catalogs` (4433), `business_unit_items` (4446) | USE NOW | Assortment per business unit (C14–C16) and the POS read (C17); `variant_is_listed` (4487) encodes the listing rule |
| `item_translations`, `item_variant_translations`, `category_translations`, `modifier_group_translations`, `modifier_option_translations` | USE LATER | Content locale negotiation is not built (`CURRENCY_AND_LANGUAGE.md`); tables are well shaped (per-entity, no generic EAV) |
| `bundle_groups` (2048), `bundle_group_items` (2170) | USED (B1, B2, B2c); `bundle_group_translations` later | The composition, the admission and the till read are built; the acyclic-graph guard (2258–2357) stays. A combo price needs Pricing |
| `menus` (2368), `menu_translations`, `menu_sections` (2506), `menu_section_translations`, `menu_entries` (2660), `menu_entry_translations`, `menu_entry_variants` (2817) | USED (M1, M2a, M2b) for `menus`, `menu_sections`, `menu_entries`, `menu_entry_variants` and `outlet_menus`; translations later | The structure ("M1 as built"), the outlet assignment ("M2a as built") and the till read by menu are built. Sales and Kitchen reference `menu_entries` / `menu_sections`, so rows are retired, never deleted |
| `item_assets` (623) | USED | The item and variant images (§5.10, built with the `assets` module) |
| `allergens` (730), `allergen_translations`, `item_allergens` (826), `dietary_tags` (917), `dietary_tag_translations`, `item_dietary_tags` (1013), `item_nutrition_profiles` (1422), `item_nutrition_facts` (1609) | USE LATER, likely RESHAPE | `DOMAIN_MAP.md` proposes a separate `catalog_food` boundary; production and crm reference allergens/dietary tags. Decide the boundary before exposing |
| `tag_groups` (1114), `tag_group_translations`, `tags` (1233), `tag_translations`, `item_tags` (1341), `menu_tags` (2467) | DROPPED | A generic tagging system had no named reader: categories already classify, and filters for the POS are categories. Migration `0075_catalog_foundations` dropped the six tables |

### 3.2 Seven-question check for the tables used now (abridged)

| Table | Writer | Reader | Invariant | Lifecycle |
|---|---|---|---|---|
| `catalogs` | `ensure_default_catalog` (lazy, on first item/category/modifier write) | every catalog read | one default per organization (`catalogs_one_default_uq`); `access_mode`/`is_default` immutable (`protect_catalog_identity`, 4269) | active forever in v1 |
| `items` | C4, C5, C7 | C1–C3, C17, C19, C20 | code unique per (organization, catalog), case-insensitive (redefined at 4426) | active ⇄ inactive; → archived ⇄ restored (§4.4) |
| `item_variants` | C4, C6 | C3, C17–C20, all downstream FKs | exactly one default per item (241 + deferred trigger 2979/2986); SKU unique per catalog (4430) | active ⇄ inactive; archived final for named variants |
| `item_variant_barcodes` | C11 | C18, C3 | value unique per catalog (4431); one active primary per variant (332) | active ⇄ inactive; never deleted (a barcode may already be printed on a receipt) |
| `categories` | C9 | C1, C2, C10, C17 | no cycle (advisory lock + trigger, 3019); code unique per catalog | active ⇄ inactive; archived |
| `item_categories` | C8 | C2, C3, C17 | one primary per item (471) | replaced as a set |
| `modifier_groups`, `modifier_options` | C12 | C3, C13, C17, Pricing later | `catalog_variant` options point to a concrete variant (`modifier_options_kind_target_chk`) | active ⇄ inactive; archived |
| `item_modifier_groups` | C13 | C3, C17 | per-item min/max; unique per item scope (2013), every status included; status `active`, `inactive` or `retired` since revision 94, every read ignoring `retired` ([`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.1, K9a) | written in place as an ordered set since K9b: a kept or re-offered link keeps its id, a removed one is retired, never deleted; the runtime holds no `DELETE` on it since revision 101 (K9c) |
| `business_unit_catalogs`, `business_unit_items` | C14, C15 | C16–C20 | ownership columns immutable; BU row locked on each write (4460) | active ⇄ inactive; never deleted |

### 3.3 Gaps: needed facts with no table

| Gap | Need | Recommendation |
|---|---|---|
| **G1 — Outlet menu assignment** (closed) | A restaurant POS shows "the lunch menu" at outlet A. No catalog table linked a menu to an outlet; `commerce.storefront_publications` (`0100_commerce.sql:438`) links outlet + menu only for an online storefront, and `pricing.price_book_assignments` (`0090_pricing.sql:133`) uses outlet + menu only to choose prices | Closed by M2a (C29, migration `0105`, baseline revision 115): `catalog.outlet_menus` holds which menus an outlet shows and in what order (up to 10). The assignment only organizes; the till read by menu follows (M2b) |
| **G2 — Catalog version for conditional reads** | The POS read is the hottest read; a `304` must be answered without re-running the full query (D24) | New table `catalog.catalog_versions(organization_id PK, version bigint)` bumped by statement-level triggers, mirroring `organization.structure_versions` (migration `0072`). Justified by C10 and C17 |
| **G3 — Idempotent create** | The item code is optional (OD2), so a retried `POST /catalog/items` would create a duplicate | Nullable `items.creation_idempotency_key` + unique partial index `(organization_id, creation_idempotency_key)`, per `FOUNDATION_HTTP_AND_OBSERVABILITY.md` §7 (claim lives in the module's own table). Needed: OD2 decided optional, generated codes. Since revision 91 (`0081`) categories, modifier groups and modifier options claim a key the same way, and the four creates store `creation_request_hash` next to it ("Idempotent creates as built") |
| **G4 — Set-based listing predicate** | `variant_is_listed` (4487) is row-at-a-time; the POS read needs the same rule as one set-based query | New `STABLE` SQL function `catalog.listed_items(p_organization_id, p_business_unit_id)` returning item ids, with a contract test that it agrees with `variant_is_listed` row for row |
| **G5 — `updated_at` maintenance** | No trigger maintains `updated_at` on catalog tables (no `updated_at =` assignment in the file); `catalogs`, `business_unit_catalogs`, `business_unit_items` have no `updated_at` at all | The application sets `updated_at` on every update (as Organization does); ETags never depend on it (they use G2 or a content hash). No new columns |

No other new table is proposed.

***

## 4. Domain model and vocabulary

### 4.1 Aggregates

| Aggregate | Root | Entities / value objects inside | Consistency boundary |
|---|---|---|---|
| **Item** | `Item` (`catalog.items`) | `Variant` (≥1, exactly one default), `Barcode` (per variant), `Classification` (category links, one primary), `ModifierOffer` (ordered group links with `SelectionRule{min,max}` and the options this item preselects, `defaultOptionIds`) | One item and everything inside it changes in one transaction, after the lock order of §5 (version row first, then the item row) |
| **Category tree** | `Category` | parent link, `sortOrder` | Re-parenting serialised per organization with `catalog.lock_hierarchy(org, 'category')` |
| **Modifier group** | `ModifierGroup` | `ModifierOption` (`instruction` or `catalog_variant`), `maxQuantity`, `sortOrder` (no default: what starts preselected belongs to each item offer, C13) | One group changes in one transaction, after the lock order of §5 (version row first, then the group row) |
| **Assortment** | `BusinessUnitAssortment` (`business_unit_catalogs` row per business unit and catalog) | `Listing` (`business_unit_items`) | Serialised by the organization's catalog version row, locked first (§5); `guard_assortment_change` then also locks the business unit row |
| **Catalog namespace** | `Catalog` | — | v1: only the default, created lazily |

### 4.2 Identifiers and codes

| Field | Rule | Source |
|---|---|---|
| ids | UUIDv7 generated by the application | `0080_catalog.sql:51` |
| `code` (item, variant, category, modifier group/option) | `^[A-Za-z0-9][A-Za-z0-9._-]{0,79}$`, unique case-insensitive per (organization, catalog) for items/categories/groups; per item for variants; per group for options | `catalog_items_code_chk`; indexes at 4426–4429 |
| `code` (combo group, menu, menu section) | Same pattern. Unique per combo (every code the combo holds or held) for combo groups; per (organization, catalog) for menus; per menu (retired sections included) for sections | B1 and M1 as built |
| Required or derived `code` | **Required** on create for variants, categories and modifier groups. **Optional** for items, modifier options, combo groups, menus and menu sections: when absent the code is derived from the name (accents dropped, lowercase ASCII, hyphens), and `-2`, `-3`… is added against the codes already taken in its scope; a name with nothing left gives `item`, `option`, `group`, `menu` or `section` | request schemas in `presentation/dtos/**` (`*-request.schema*.ts`); `domain/items/item-code.ts`, `domain/shared/part-code.ts` |
| `sku` | optional, non-blank, ≤120, unique case-insensitive per (organization, catalog) | 248, 4430 |
| `barcode` | non-blank, ≤160, unique per (organization, catalog); optional `symbology` from a closed list | 269–331, 4431 |
| names | `trimmedBoundedText` from `foundation/validation`, reject NUL (`AGENTS.md` §4); item name ≤255, category/group/option ≤180 | column sizes |
| `salesUnitCode` | FK to `reference.units_of_measure` (seeded: `unit`, `g`, `kg`, `ml`, `l`, ...) | 140–200 |
| quantities | `minimumSalesQuantity > 0`, `salesQuantityStep > 0`, `maximum ≥ minimum`; exact decimals as strings through the `decimal.js` wrapper, never `number` (`AGENTS.md` §4) | `item_variants_sales_quantity_policy_chk` |

*Decided:* the default variant of a simple item has `code = 'default'` and
`name = NULL`; a restaurant never sees it in the UX (`0080_catalog.sql:35-36`).
The API returns `variants` always, with `isDefault`, so the client can hide a
single default variant.

### 4.3 Scoping rules

| Fact | Scope | Grounding |
|---|---|---|
| Item, variant, barcode, category, modifier group | **Catalog namespace** (v1: the organization's default catalog), i.e. organization-wide | `catalog_id NOT NULL` on every master row; `ARCHITECTURE.md` §14: business units share the "catálogo base" |
| Whether a business unit sells an item | **Business unit** | `business_unit_catalogs`, `business_unit_items`; `APP_SHELL_AND_ONBOARDING.md` line 34: "El catálogo compartido respeta asignación y surtido" |
| What an outlet's POS shows | **Outlet → its effective business unit today** | `organization.effective_business_unit(org, outlet, site date)` (the same resolution `platform.authorize_operation` uses) |
| Prices per brand/outlet/menu | Not catalog | `pricing.price_book_assignments` |
| Legal entity, brand, site | Not a catalog scope in v1 | No catalog table carries them; Pricing and Sales freeze legal entity (D04) |

### 4.4 Lifecycle states

Database states are `active`, `inactive`, `archived` for items, variants,
categories, groups and options (`*_status_chk`); `active`, `inactive` for
barcodes and assortment rows. *Decided* meaning and transitions:

| State | Meaning | Visible in POS read | Visible in back-office list |
|---|---|---|---|
| `active` | Sellable where listed | yes | yes (default filter) |
| `inactive` | Temporarily withdrawn everywhere (seasonal dish, paused product) | no | yes, with filter `status=inactive` |
| `archived` | Retired; kept because downstream history references the variant | no | only with `status=archived` |

```text
             deactivate                 archive
  active ─────────────────▶ inactive ─────────────▶ archived
     ▲  ◀───────────────── │   ▲                       │
     │      reactivate     │   └────── restore ────────┘
     └────────── archive (direct) ─────────────────────▶
```

* `archive` is allowed from `active` or `inactive`; `restore` returns
  an archived item to `inactive` (never straight to `active`), so a restored
  item is reviewed before it sells again. (Restorability itself is OD8.)
  Variants follow the same table since API v7 PR 2, and categories,
  modifier groups and modifier options since API v7 PR 3 (each with its own
  `restore`). The names in the diagram are the closed list a client sends to
  `POST .../transitions` (C7, C9, C12). `deactivate` and `archive` never need
  the plan; `reactivate` and `restore` do.
* Nothing is ever hard-deleted by the runtime (no `DELETE` policy exists on
  master tables; the header says "archived/inactivated rather than
  hard-deleted", `0080_catalog.sql:60-62`).
* The default variant cannot be deactivated or archived while its item is
  `active`; to retire a variant that is default, first make another variant
  the default (`assert_item_default_variant`, 2900).
* Menus have their own `draft/active/archived` (2368): created draft, `activate` (draft → active, gated) when at least one entry is available, `unpublish` (active → draft), `archive` (draft or active → archived) and, since API v7 PR 3, `restore` (archived → draft, gated, with its structure); archiving is no longer final (M1). Sections and entries are `active`, `inactive` (SQL only) or `retired`.

***

## 5. Use cases

**Conventions for every case.** Routes are relative to `/api/v1`; every
request carries a session and `X-Organization-Id` (O3; `TenantContextGuard`).
Bodies are strict Zod schemas that reject unknown fields (D24). Responses
use `{ data }` or `{ data, meta }` (`FOUNDATION_HTTP_AND_OBSERVABILITY.md`
§1). `detail` is English and technical; the client translates `code` (D25).
Every command that changes something writes one tenant audit record through
`TenantAuditContract` in the same transaction, only for a real change
(as O11).

**Lock order (one rule for every catalog command).** Every catalog command
first locks the `catalog.catalog_versions` row of its organization with
`SELECT ... FOR UPDATE`, before it locks or writes any item, variant,
category, modifier group or business unit row. The reason: every catalog
write fires the statement-level bump trigger, which updates that same row
and holds its lock until commit, so a command that locked an item first and
bumped later could deadlock with another command that bumped first and then
asked for the same item. Taking the version row first serializes the
catalog commands of one organization in a single order. After it, a command
may lock the rows it edits (`FOR UPDATE`) or reads for validation
(`FOR SHARE`), in id order. Reads and `CatalogSellableContract.resolve`
(C20) do not take it. `CatalogSellableContract.admit` (C20, which writes
nothing in the catalog) shares it (`FOR SHARE`) before anything else, so
admissions and catalog commands never deadlock. The cost falls on the
catalog writer, not on the sale: a shared lock does not queue behind a
waiting `FOR UPDATE`, so new admissions keep entering while a command waits,
and the command proceeds only once no admission of the organization is open;
under continuous overlap of sale transactions it fails after
`DATABASE_STATEMENT_TIMEOUT_MS` (5,000 ms), not after
`DATABASE_LOCK_TIMEOUT_MS`, which PostgreSQL applies to each lock
acquisition separately ("K8 as built", the Sales requirement and the measured
gate).

**Update lists (K2 repositories).** UPDATE is granted on whole tables, so
the repository is what keeps identity columns out of a write: an UPDATE
never includes `item_id`, `organization_id`, `catalog_id` or `created_at`
in its `SET` list, not even with an unchanged value. Naming `item_id` on
`item_variants` fires the bundle-graph guard triggers; until revision 110
the runtime could not execute their functions and the statement failed with
`42501`, and since B1 (which grants `EXECUTE` on
`catalog.lock_bundle_graph` and `catalog.assert_bundle_composition_acyclic`)
it would take the bundle-graph lock and re-check the composition graph, so
the rule stays; the others are immutable and their triggers refuse them.

Every command answers the shared rows of this table, which are not repeated
per case:

| Shared path | Answer |
|---|---|
| No session | `401 · identity.session_required` |
| Organization not selectable | `403 · tenant.context_rejected` |
| Permission missing at the anchor | `403 · catalog.action_forbidden` (*new*, audited as denied, like `organization.action_forbidden`) |
| Scope assigned to an inactive place | `403 · authorization.scope_unavailable` |
| Capability not in the plan (gated commands only) | `403 · platform.capability_not_entitled` |
| Business unit anchor inactive | `422 · authorization.place_not_operational` |
| Outlet anchor not operating | `422 · authorization.outlet_not_operational` |
| Malformed id, code or body | `400 · foundation.validation_failed` with `meta.errors` |
| Unknown or foreign-tenant catalog object | `404 · catalog.not_found` (*new*; RLS makes "another tenant's" and "does not exist" identical) |
| Unknown or foreign-tenant business unit / outlet in the path | `404 · organization.structure_not_found` (reused, as session context does) |
| `If-Match` stale | `412 · foundation.precondition_failed`, nothing written |
| Unexpected constraint not mapped | `422 · catalog.item_refused` (*new*, generic backstop like `organization.structure_refused`) |

**Authorization helpers.** "Org-wide" means `OperationAuthorizationContract`
with no outlet (satisfied only by an organization-wide scope, as every
Organization command today). "At BU" means `platform.authorize_operation_at
(permission, capability, 'business_unit', id)`. "At outlet" means
`PermissionCheckContract.decide` with `outletId`. Ungated commands use
`PermissionCheckContract` (no capability step); gated ones use
`OperationAuthorizationContract`. Permission and capability codes are in §7.

### 5.1 Reads for the back office

#### C1 — Open the catalog workspace

**Actor:** member with `catalog.items.view` (org-wide, or at the chosen
business unit). **Intent:** the first screen of "Products" in two requests
(D10: `GET /session/context` + `GET /<module>/workspace`,
`API_AND_SCHEMA_DISCIPLINE.md` §2.A).
**Route:** `GET /catalog/workspace?businessUnitId=` (optional).
**Response essentials:** `catalog {id, code, name}`, `counts {active,
inactive, archived}` (bounded `count(*)` on an index), `categories` (the
whole tree, capped at 500 nodes with `truncated`), the first page of items
(same shape as C2, `limit` 50, `primaryImage` included since API v7 PR 7), `assortment {mode}` when `businessUnitId` is
given, `allowedActions` derived from the same permission checks (context,
never authority).
**Statuses:** `200`. No ETag (it contains a cursor page, D24). Not
capability-gated (a tenant whose plan lost the catalog must still see and
repair it).
**Scenario rows:** happy path org-wide; BU-scoped member with
`businessUnitId` of its BU → 200; same member without `businessUnitId` →
403; BU-scoped member with another BU → 403; foreign BU → 404; empty
catalog (no default catalog yet) → 200 with zero counts and no write;
category tree over 500 → `truncated: true`.
**Statements (since K10b):** the warning `units_with_different_verticals`
comes from the contracts, not the facts statement: an organization-wide
curator adds `OrganizationContextContract.activeBusinessUnits` (one statement)
and, when two or more units are active, `BusinessUnitVerticalsContract.activeVerticals`
(one more); every other reader adds none (`CATALOG_OPTIONS_V2.md` §6.2).

#### C2 — List and search items

**Actor/permission:** as C1.
**Route:** `GET /catalog/items?q=&status=&categoryId=&includeDescendants=&hasImage=&businessUnitId=&listed=&after=&before=&limit=`.

* `q`: prefix match on name, code or SKU, case- and accent-insensitive
  (`cafe` finds `Café`, `CAFÉ` and `Cafè`; `nandu` finds `Ñandú`), 1–80
  chars (*Decided*: prefix, not substring; §8.3).
* `status`: one or several of `active`, `inactive`, `archived`, separated
  by commas (`status=active,inactive`), each once; `active` when absent
  (API v7 PR 7).
* `categoryId`, and `includeDescendants=true` to add the categories below it
  (two levels at most: a tree is three deep); `hasImage=true|false` keeps
  the items with or without a primary image of their own (API v7 PR 7).
* `businessUnitId` + `listed=true|false`: only items the unit sells / does
  not sell (requires the BU anchor).
* Keyset order `(lower(name) COLLATE "C", id)`; `limit` default 50, max 100;
  signed opaque cursor (`CURSOR_SIGNING_KEY`). *Decided:* catalog lists are
  ordered by name, not `created_at`, because people scan products
  alphabetically; still keyset, never `OFFSET` (`lint:no-offset-paging`).
  **Response:** `data: [{id, code, name, status, defaultVariant {id, sku,
  salesUnitCode}, variantCount, primaryCategory {id, name}, primaryImage
  {assetId, thumbnailUrl} | null}]`, `meta.pageInfo` (`primaryImage` since API
  v7 PR 7, described in one Assets call per page).
  **Statuses/codes:** `200`; `400 · collection.cursor_invalid |
  cursor_tampered | cursor_expired | cursor_incompatible` (a cursor from
  another filter set is incompatible).
  **Scenario rows:** pagination stability under concurrent inserts (no
  duplicate, no skip across pages); prefix on code vs name vs SKU; accent and
  case (`Ñandú` vs `ñandú`); `q` with `%` or `_` is escaped (no wildcard
  injection); tenant isolation with identical codes in two organizations;
  `limit=101` → 400.

#### C3 — Read one item

**Actor/permission:** as C1.
**Route:** `GET /catalog/items/{itemId}`.
**Response:** the full aggregate: item fields, `variants[]` (with
`barcodes[]`), `categories[]` (with `isPrimary`), `modifierGroups[]` (with
`minSelections`, `maxSelections`, `sortOrder`, and the group's options).
Since K9g each item group carries `suspended` and each variant carries
`modifierGroupOverrides[]`: the groups that variant overrides (`optedOut:
false`, its own rule and defaults) or drops (`optedOut: true`), in order,
each with the group's options; retired rows never appear.
**ETag:** *Decided* a content hash of the returned aggregate (D24 allows
"hash del contenido"), `Cache-Control: private, no-cache`; `If-None-Match`
match → `304` with empty body. A per-item hash, not the organization-wide
version, so two people editing different items never collide on `If-Match`
(the cost D24 accepted for the organization structure is avoided here
because catalog editing is far more concurrent).
**Scenario rows:** ETag changes after a variant is added, a barcode is
deactivated, the item is reclassified, a modifier group attached to it is
renamed; old `If-None-Match` no longer gives 304; archived item still
readable (200).

### 5.2 Item commands

#### C4 — Create an item

**Actor:** `catalog.items.manage` org-wide; capability `catalog.items`.
**Route:** `POST /catalog/items`.
**Request:** `{ code?, name, description?, categoryIds?, primaryCategoryId?,
defaultVariant: { sku?, salesUnitCode = 'unit', minimumSalesQuantity = '1',
maximumSalesQuantity?, salesQuantityStep = '1' }, variants?: [{ code, name,
sku?, ... }] }`.
**Main path:** one transaction: lock the catalog version row (lock order,
§5) → `ensure_default_catalog` → insert item →
insert default variant (`is_default = true`, `code = 'default'`) → optional
named variants (≤ 50 per request) → optional category links → audit
`catalog.item.created` → bump `catalog_versions` (trigger). The deferred
constraint trigger (2979) proves the default-variant invariant at commit.
**Response:** `201` with the C3 shape and its `ETag`.
**Idempotency:** with a client-supplied `code`, a retry fails loudly with
`409 · catalog.code_taken` and never duplicates (the K1 posture, as `POST
/brands`, which has no `Idempotency-Key`; superseded in K2, see below). Because OD2 made `code` optional
and generated from the name, `Idempotency-Key` is required when `code` is
omitted (G3). *As built in K2:* the route requires `Idempotency-Key` on every
create, with or without a code, so a client has one rule and a retry always
replays instead of answering `409` (see "K2 as built"). Since revision 91
the replay compares the SHA-256 of the original request, stored next to the
key in `creation_request_hash`, not the item as it is now: a retry with the
same key and body after the item's name, code or description was edited
answers `201` with the item as it is now and `Idempotent-Replayed: true`,
and the same key with another request `409 · idempotency.key_reused`. An
item created before revision 91 has a key without a hash and is still
compared with the item as it is now (see "Idempotent creates as built");
`idempotency.key_reused` / `idempotency.in_progress` as the foundation
contract.
**Errors:** `409 · catalog.code_taken` (`catalog_items_code_ci_uq`), `409 ·
catalog.sku_taken` (`item_variants_sku_ci_uq`), `422 · catalog.unit_unknown`
(*new*, `item_variants_sales_unit_fk`), `422 · catalog.quantity_policy_invalid`
(*new*, `item_variants_sales_quantity_policy_chk`), `404 · catalog.not_found`
for an unknown category id, `422 · catalog.category_archived` for an
archived category; an inactive category is accepted (owner decision
2026-10-05, "Category lifecycle and effective visibility").
**Plan limit:** none in v1 (OD6). If a limit is introduced it is
`catalog.active_items`, checked under a per-organization lock like
`identity.assert_membership_seat_available`.
**Scenario rows:** happy path simple item (one default variant); item with
three sizes; duplicate code differing only in case → 409; SKU duplicated
across two items → 409; two concurrent creates with the same code → one
201, one 409 (unique index); member with the permission only at a BU →
403; plan without `catalog.items` → 403 capability; foreign category id →
404; NUL byte in name → 400; audit record rolls back with a failing insert.

#### C5 — Update an item's profile

**Actor:** as C4 (gated).
**Route:** `PATCH /catalog/items/{itemId}` with optional `If-Match` (C3 ETag).
**Request:** any of `code`, `name`, `description`, and `defaultVariantId`
(with `formerDefaultName` only alongside it) to choose the default variant
(C6).
**Main path:** lock the catalog version row, then `SELECT ... FOR UPDATE`
on the item (lock order, §5) → compare `If-Match`
against the recomputed hash inside the transaction → update →
`catalog.item.updated` with flat `changes[]` (`code`, `before`, `after`).
**Statuses:** `200` with the new ETag; unchanged body → `200`, nothing
written or audited.
**Scenario rows:** rename; code change to a taken code → 409; stale
`If-Match` → 412 with no write; two concurrent PATCHes with the same
`If-Match` → one 200, one 412; archived item → `422 · catalog.item_archived`
(*new*: an archived item is read-only until restored).

#### C6 — Manage variants

**Actor:** as C4 (gated, except deactivation which is ungated).
**Routes:**

* `POST /catalog/items/{itemId}/variants` — add a named variant.
* `PATCH /catalog/items/{itemId}/variants/{variantId}` — `code`, `name`,
  `sku`, sales-quantity policy.
* `PATCH /catalog/items/{itemId}` with `defaultVariantId` (and
  `formerDefaultName` when the current default has no name) — make it the
  default (the old default stays active, `is_default = false`); a variant
  that is not active `422 · catalog.variant_not_active`, a former default
  without a name `422 · catalog.variant_name_required`, a variant of
  another item `404 · catalog.not_found`.
* `POST /catalog/items/{itemId}/variants/{variantId}/transitions` with
  `{ "transition": "deactivate" | "reactivate" | "archive" | "restore" }`,
  the same table and answer shape as C7 (`{ variantId, status }`). A
  restored variant comes back `inactive` with its code, SKU and place among
  the 500 variants of the item (all kept while archived, so nothing else
  needs checking) and its barcodes inactive.
  **Invariants:** exactly one default per item; a named (non-default) variant
  needs a name (`item_variants_named_non_default_chk`); a variant never moves
  to another item (`item_variants_id_item_organization_uq`; catalog namespace
  immutable, `catalog_namespace_immutable`).
  **Concurrency:** the catalog version row first (lock order, §5), then the
  item row `FOR UPDATE`, then the variants, in id order. `If-Match` with the
  item ETag.
  **Audit:** `catalog.item.variant_added`, `catalog.item.variant_updated`,
  `catalog.item.default_variant_changed`, `catalog.item.variant_deactivated`,
  `..._reactivated`, `..._archived`, `..._restored` (resource kind
  `catalog.item`, the variant in the change code, as O11 does with the
  vertical); `catalog.item.variant_restored` is seeded since revision 112
  (migration `0102`, API v7 PR 3); before it a restore was recorded as
  `catalog.item.variant_updated`.
  **Errors:** `422 · catalog.default_variant_required` (*new*: deactivating or
  archiving the default; backstops the deferred trigger), `409 ·
  catalog.code_taken` (`item_variants_item_code_uq`), `409 · catalog.sku_taken`.
  **Scenario rows:** swap default and deactivate the old one in two calls;
  deactivate default → 422; two concurrent default selections on the same
  item → serialised, last wins, exactly one default at commit; variant id of
  another item in the path → 404; changing `salesUnitCode` on a variant already
  sold is allowed (Sales snapshots it; documented consequence).

#### C7 — Deactivate, reactivate, archive, restore an item

**Actor:** `catalog.items.manage` org-wide. `deactivate` and `archive` are
**never capability-gated** (*Decided*: withdrawing something from sale must
work even after a plan downgrade, the "administrator can always repair"
rule); `reactivate` and `restore` are gated by `catalog.items`.
**Route:** `POST /catalog/items/{itemId}/transitions` with optional
`If-Match` (the C3 ETag) and no `Idempotency-Key`: a transition names its
intent, so repeating it is safe.

```json
POST /api/v1/catalog/items/0193…a1/transitions
{ "transition": "deactivate" }

200 OK
ETag: "<strong tag of the whole item, as C3 answers it>"
{ "data": { "itemId": "0193…a1", "status": "inactive" }, "meta": {} }
```

**Transitions:** §4.4; the closed list is `deactivate`, `reactivate`,
`archive`, `restore`, derived from the lifecycle table
(`domain/items/item-lifecycle.ts`). Repeating a transition already in effect
→ `200`, nothing written (as O11). The answer is lean: read the item (C3)
for the rest.
**Effects:** none on assortment rows, barcodes or modifier links; the POS
read stops showing it because the listing rule requires `i.status =
'active'` (`variant_is_listed`, 4487). Existing open sales keep their
snapshot (Sales owns that).
**Audit:** `catalog.item.deactivated`, `.reactivated`, `.archived`,
`.restored`.
**Errors:** `422 · catalog.transition_invalid` (*new*, e.g. `restore` of
an active item, `reactivate` of an archived one); its `detail` teaches
where the item stands, for example `The item is archived; from archived it
allows restore. Read it again and send one of those transitions.` A
transition outside the closed list, or any other field, → `400 ·
foundation.validation_failed`.
**Scenario rows:** each legal transition; each illegal one → 422; archived
item absent from C17 and from `admit` (C20); `reactivate` after a plan
downgrade → 403 capability (`reactivate` and `restore` **are** gated: they
put something back on sale), while `deactivate` and `archive` succeed.

#### C8 — Classify an item

**Actor:** as C4 (gated).
**Route:** `PUT /catalog/items/{itemId}/categories` with `{ categories: [{categoryId, isPrimary}], ... }` (≤ 20 links; at most one primary).
**Main path:** version row, then item `FOR UPDATE` (lock order, §5) →
delete links not in the set → upsert the
rest → one audit record `catalog.item.categories_set` with before/after id
lists. `PUT` because the request carries the complete classification, a
task ("set this item's categories"), not row CRUD.
**Errors:** `422 · catalog.primary_category_conflict` (*new*, two primaries;
backstops `item_categories_one_primary_uq`), `422 ·
catalog.category_archived` for an archived category added to the item (an
inactive one is accepted; a link the item already has may stay after its
category was archived), `404 · catalog.not_found`.
**Scenario rows:** replace set; empty set → item unclassified; idempotent
replay of the same body → 200 without audit.

### 5.3 Categories

#### C9 — Create, rename, move and retire a category

**Actor:** `catalog.items.manage` org-wide (create, edit, move,
`reactivate` and `restore` gated; `deactivate` and `archive` ungated).
**Routes:** `POST /catalog/categories`, `PATCH /catalog/categories/{id}`
(`code`, `name`, `description`, `sortOrder` and, since API v7 PR 3,
`parentCategoryId` (a category id or `null` for the root) to move it in the
same locked transaction; it replaced `POST .../re-parenting`),
`POST /catalog/categories/{id}/transitions` with `{ "transition":
"deactivate" | "reactivate" | "archive" | "restore" }` (API v7 PR 3: it
replaced the three per-verb routes and added `restore`), and since revision
91 `GET /catalog/categories/{id}`.
**Idempotency and preconditions (revision 91):** the create requires
`Idempotency-Key` with the C4 semantics (a retry replays the category as it
is now, even after an edit; the same key with another request answers
`409 · idempotency.key_reused`; a taken code under another key still
answers `409 · catalog.code_taken`). One category is read with its own
strong ETag, a hash of the category as answered, its effective visibility
and depth included; every category write answers that tag and accepts it
in an optional `If-Match` (`412 · foundation.precondition_failed`, never
required). See "Idempotent creates as built".
**Invariants:** no cycle (`categories_prevent_cycle_trg`, 3057, under
`catalog.lock_hierarchy(org,'category')`, 2999 — which also asserts READ
COMMITTED); *Decided* maximum depth 3 (domain rule; a POS cannot render a
deeper tree usefully); the parent must not be archived (an inactive parent
accepts new and moved subcategories, owner decision 2026-10-05); an
organization holds at most 500 categories of any status, archived ones
included because the tree still loads them (`422 ·
catalog.category_limit_reached`, hardening round), so C10 always answers
the whole tree.
**Effects of deactivating a category:** its items stay sellable; the POS
read shows them under "Other" when their primary category is inactive
(*Decided*: hiding products because a folder was hidden surprises owners).
Its subcategories keep their own status and become effectively hidden
(see "Category lifecycle and effective visibility" below).
**Restore (API v7 PR 3):** `archived → inactive` under the same parent,
gated; the code stayed unique and the category kept counting toward the
500, so only its place is judged again: `422 · catalog.category_archived`
while the parent is archived (restore the parent first) and `422 ·
catalog.category_depth_exceeded` if it would nest too deep.
**Audit:** `catalog.category.created`, `.updated`, `.moved`,
`.deactivated`, `.reactivated`, `.archived`, `.restored`; resource kind
`catalog.category`. A `PATCH` that edits and moves writes one `.updated`
and one `.moved` record.
**Errors:** `422 · catalog.category_cycle` (*new*), `422 ·
catalog.category_depth_exceeded` (*new*), `409 · catalog.code_taken`.
**Scenario rows:** move under own descendant → 422; two concurrent moves
creating a cycle together → one fails (advisory lock); depth 4 → 422;
foreign parent id → 404.

#### C10 — Read the category tree

**Actor:** `catalog.items.view` org-wide or at a BU, or
`catalog.sellables.view` at an outlet (the POS needs the tree).
**Route:** `GET /catalog/categories?status=active`.
**ETag:** strong, derived from `catalog_versions.categories_version` since
API v7 PR 7 (migration `0104`); bounded (≤ 500 nodes), not cursor-paged, so
it qualifies under D24. The tag reads `organization:category-tree:<tree version>:<status or all>`, a form no earlier tag can take. The tree version moves only on a category write of
the organization (an insert, a delete, or a change of parent, code, name,
description, sort order or status); item writes, classifications included,
keep the tag. Until PR 7 the tag used the organization-wide version, so any
catalog write invalidated it.
**Scenario rows:** ETag changes on any category write; ETag stays across item
writes and classifications of the same organization; ETag never changes on
another organization's write; old `If-None-Match` after a category rename no
longer gives `304`.

#### Category lifecycle and effective visibility

Owner decisions of 2026-10-05 ("the best for the business"); no SQL object
and no migration. A category has its **own `status`**, set only by its own
lifecycle routes, and an **effective visibility** that is derived, never
stored.

| # | Rule (*Decided* 2026-10-05) |
|---|---|
| 1 | A category's status change never changes the status of its descendants |
| 2 | A category is effectively active only when it and **all** its ancestors have status `active`; this is computed on every read, never stored |
| 3 | Reactivating a parent changes no child: each child keeps its own status, so a child that was inactive stays inactive and a child that was active becomes visible again |
| 4 | A category lifecycle change never changes any `Item.status` or `Variant.status` |
| 5 | An item may belong to several categories, with one primary |
| 6 | An **inactive** category may receive new subcategories and be reorganized (children created under it, categories moved under it, its children reactivated). An **archived** category is read-only until it is restored (`restore`, API v7 PR 3): creating a child under it, moving a category under it and editing it answer `422 · catalog.category_archived`. Reactivating a child never looks at the parent's status. The cycle, maximum depth 3, same catalog and same organization rules stay |
| 7 | New classifications (C8, and C4 with `categoryIds`) are refused only for an **archived** category (`422 · catalog.category_archived`); an inactive one is accepted; a link the item already has may stay |
| 8 | The hierarchy is navigation and classification, **not** a source of sellability: K6 and K7 add no category condition to sellability (C17, C19, C20) |
| 9 | Point-of-sale and other read models must hide a branch whose ancestor is not active without touching its descendants (K7 requirement, see C17 and C19). The back-office item list `categoryId` filter and C8 stay as they are: they work on the link, whatever the category's visibility |
| 10 | The back office distinguishes the own `status` from the effective visibility: every category representation carries `isEffectivelyActive` and `blockedByAncestorId` |

**`blockedByAncestorId`** is the id of the **nearest** ancestor whose status
is not `active`; it is `null` when no ancestor hides the category, also
when the category itself is the cause (an inactive category under active
ancestors answers `isEffectivelyActive: false`, `blockedByAncestorId:
null`).

**Example.** *Bebidas* (root) with *Gaseosas* (active) and *Jugos*
(inactive), and *Naturales* (active) under *Jugos*. Deactivating *Bebidas*
answers it with `isEffectivelyActive: false` and `blockedByAncestorId:
null`; the tree then shows *Gaseosas* and *Jugos* with `blockedByAncestorId`
\= *Bebidas*, and *Naturales* with *Jugos* (nearer). Their own status does
not move. Reactivating *Bebidas* makes *Gaseosas* effectively active again,
while *Jugos* stays inactive and *Naturales* stays hidden by it.

**Where it is computed.** In the application layer, from the hierarchy,
with one parent map and a memo, O(n):
`GET /catalog/categories` without `status` and `GET /catalog/workspace`
derive it from the tree they already read in one statement, ordered depth
first, so an ancestor always comes before its descendants and the 500-node
cap never drops the ancestor of a kept node. With a `status` filter the
ancestors may be filtered out, so the read adds one statement, the
organization's hierarchy (`id`, `parent_category_id`, `status`). Every
category write answer (create, edit or move, transition) reads the
written category and then the hierarchy once, in the same transaction. The
item representation's `categories[]` carries no status, so it gains no
field and the item ETag is unchanged by this rule; the tree ETag derives
from the organization-wide catalog version, which every category write
bumps, so a status change of an ancestor changes it.

### 5.4 Barcodes (retail)

#### C11 — Set the barcodes of a variant

**Actor:** `catalog.items.manage` org-wide; a set that adds, brings back,
makes primary or changes a symbology is gated, one that only suspends,
archives or drops the primary never is.
**Route (API v7 PR 4):** `PUT /catalog/items/{itemId}/variants/{variantId}/barcodes`
with `{ barcodes: [{ barcodeId?, barcode, symbology?, isPrimary?, suspended? }] }`,
`If-Match` required (the item tag). It replaced `POST .../barcodes`,
`PATCH .../barcodes/{barcodeId}` and
`POST .../barcodes/{barcodeId}/{deactivation,reactivation}`.
**Validation:** *Decided* check digit validated server-side for `ean13`,
`ean8`, `upca`, `upce` (a mistyped EAN is the most common retail data
error); other symbologies only non-blank and ≤160. At most 20 barcodes,
each value (as a GTIN) and id once, at most one primary.
**Invariants:** unique value among the active barcodes of a catalog (index
`item_variant_barcodes_value_uq`, the final arbiter), one active primary per
variant. A barcode is never deleted and never re-pointed to another variant
(*Decided*: re-pointing would silently change what past scans meant): one
left out of the set is archived (status `archived`, migration `0103`), never
scans, frees its value and no longer counts toward the 20; sending its value
or id again brings it back with the same id. Bringing a value to life fails
when it is active on another variant.
**Audit:** one `catalog.item.barcodes_set` per set change, with the variant
and the live set before and after.
**Errors:** `409 · catalog.barcode_taken`, `422 · catalog.barcode_invalid`,
`422 · catalog.barcode_value_fixed`, `422 · catalog.barcode_not_active`,
`404 · catalog.not_found`, each pointing at its entry (`barcodes[i].barcode`,
`.barcodeId`, `.isPrimary`).
**Scenario rows:** same EAN on two variants → 409; same EAN in two
organizations → both 200; bad check digit → 422; two saves with the same
tag → one 200, one 412.

### 5.5 Modifiers (restaurant first, vertical-neutral)

#### C12 — Define a modifier group and its options

**Actor:** `catalog.items.manage` org-wide; capability `catalog.items`
(*Decided*: no separate capability in v1; OD6).
**Routes:** `POST /catalog/modifier-groups` (`{ code?, name, description?,
options: [{ code?, name, kind: 'instruction'|'catalog_variant',
variantId?, maxQuantity = 1 }] }`, ≤ 50 options; an option has no
default of its own since the hardening round, see C13),
`PUT /catalog/modifier-groups/{groupId}` (API v7 PR 5: the group profile
and its whole ordered option set in one call, `If-Match` required; it
replaced the `PATCH` of the group and the `POST` and `PATCH` of an option,
see "API v7 PR 5 as built"),
`POST /catalog/modifier-groups/{groupId}/transitions` and
`POST /catalog/modifier-groups/{groupId}/options/{optionId}/transitions`
with `{ "transition": "deactivate" | "reactivate" | "archive" | "restore" }`
(API v7 PR 3: they replaced the six per-verb routes and added `restore`,
which brings an archived group or option back as `inactive`; an option only
while its group is not archived).
**Idempotency (revision 91):** the group create requires
`Idempotency-Key` with the C4 semantics. The set needs none: it names the
version it replaces in `If-Match`, so a retry of a set that already landed
answers `412`, and an option sent again by its code takes its id back.
**Invariants:** `instruction` has no variant; `catalog_variant` points to a
variant of the same catalog (`modifier_options_kind_target_chk`, namespace
FK); `maxQuantity` 1–999; an organization holds at most 500 groups of any
status (`422 · catalog.modifier_group_limit_reached`, hardening round), so
the bounded list always answers them all. Deactivating or archiving an
option, deactivating or archiving its group, or deactivating or archiving
the variant a `catalog_variant` option sells removes that option from the
`defaultOptionIds` of every item offer in the same transaction (one
`DELETE`), recorded as the number of offers that lost a default;
reactivating never adds it back. *Decided*: a `catalog_variant` option may point
only to an `active` variant at the time it is set; later deactivation of
that variant hides the option in C17 instead of failing.
**Units, not options (F1, 2026-10-07):** what a group can give an offer is
the sum of the `maxQuantity` of its available options (active, and for a
`catalog_variant` option a target variant and item that are active, the
structural `activeOption` rule of the writes; since K16b the reads also ask the
target to be listed and shown, `availableOption`), the arithmetic of the Sales group check
(`0110_sales.sql`, the sum of `quantity`). Deactivating or archiving an
option, pausing the target of an extra or lowering a `maxQuantity` is never
refused because of the offers that use the group: none of these writes
judges the offers again (C13, K5 design note); an item whose required
minimum the options left can still reach in units stays on sale (one
Cheddar of `maxQuantity` 2 keeps a minimum of two), and one whose minimum
they can no longer reach leaves every reader until an option comes back.
**Audit:** `catalog.modifier_group.created`, `.updated` (one per set, with
`catalog.modifier_group.option_set` before and after since API v7 PR 5),
`.deactivated`, ...; resource kind `catalog.modifier_group`
(`.option_added` and `.option_updated` stay seeded for the records written
before PR 5).
**Scenario rows:** "Cooking point" with three instructions; "Extras" with
`catalog_variant` options; option pointing to another tenant's variant →
404; to an archived variant → 422 `catalog.variant_not_active` (*new*);
to a variant of a combo → 422 `catalog.option_target_not_plain` (K16b);
two sets of one group sent at the same version → the catalog version row
and the group lock serialise them, the second answers `412` (lock order,
§5).

#### C13 — Offer modifier groups on an item

**Actor:** as C4 (gated), except a list that only removes groups (every
group sent is already offered with the same rule, fewer are sent, the empty
list included), which is ungated like a deactivation (owner decision
2026-10-05, "the best for the business": a lapsed plan can always detach).
**Route:** `PUT /catalog/items/{itemId}/modifier-groups` with `{ offers: [{ modifierGroupId, minSelections, maxSelections, defaultOptionIds = [],
suspended = false }], variantOverrides?: [{ variantId, offers: [{ ...,
optedOut = false }] }] }`: `offers` is the ordered item list (≤ 20);
`variantOverrides` (≤ 10 variants per request, API v7 PR 5) replaces the
list of each variant sent. A variant not sent, and every variant when
`variantOverrides` is absent, keeps its overrides; one sent with an empty
list clears them. `If-Match` is required (`428`/`412`).
**Invariants:** `0 ≤ min ≤ max`, `max ≥ 1`, `min ≤` the units the group's
available options can reach, the sum of their `maxQuantity` (domain rule, so
a required group can always be satisfied; since F1, before it the number of
active options, which refused one Cheddar of `maxQuantity` 2 offered with a
minimum of two although Sales and `admit` accept Cheddar × 2); `max` may
exceed those units, as before (the options left may grow, and `admit`
judges each option against its own maximum);
group active. *Decided (owner, hardening round, "the best for the
business")*: the preselected options belong to the offer, not to the
reusable option, so the same group can start "Queso" on the classic burger
and "Tocino, Queso" on the bacon burger; `defaultOptionIds` holds each
option once, at most `maxSelections` of them (an empty list is always
valid), and only options that are active, in an active group, whose
`catalog_variant` target variant and its item are active (`422 ·
catalog.default_options_invalid`; since K8 the command judges it with the
structural `activeOption` fragment of the till, which has no unit; the same answer for an
unknown option, one of another group or of another
organization). A change of defaults is a rule change: gated like any
change that is not a pure removal of groups. *Decided (K14a)*: a default may
start above one unit through the optional `defaultQuantities` (a map by option
id, 1 to 999, only for defaults above one unit; absent means every default
counts one), judged after the defaults (`422 ·
catalog.default_quantities_invalid`: a quantity above the `maxQuantity` of its
option, or units that add up to more than `maxSelections`; a key that is not a
default of the same offer is a `400` at the door); changing only a quantity, up or down, is a rule
change and needs the plan. *Decided (K14b)*: an offer may
lower or withhold an option for itself through the optional `optionLimits` (a
map by option id, 0 to 999, always below the `maxQuantity` of the option, 0 meaning the option is not
offered in this offer; `422 · catalog.option_limits_invalid`), judged before the
rule; the minimum is judged against what the limited options can reach, a
default cannot be an option limited to 0 and a default quantity cannot exceed
the limit; changing only the limits is a rule change and needs the plan. Per-variant offers (`item_variant_id` on the link) were
first decided out of v1 (one offer set per item); **reversed by the owner on
2026-10-07**: per-variant offers ship, a variant-level offer of a group
replacing the item-level one for that variant exactly as the Sales trigger
resolves it, designed in [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md)
(OV9, slices K9f and K9g). Since K9f every reader applies that rule per
variant (C17 to C20); since K9g the variant route below writes them.
**Locks:** the catalog version row first (lock order, §5), then the item
`FOR UPDATE`, then the groups `FOR SHARE` in id order.
**Identity (K9b):** the command never deletes an offer. A group kept in
the list keeps its offer id while its rules or its place change (updated in
place); only a group the item never offered gets a new id; a group left out
is retired (`retired`, its defaults removed) and stays for the rows of
Pricing, Commerce and Sales that reference it; offering it again revives the
same id with the rules sent. A retired offer is a new group for the command:
it must be active and the change is gated. **Suspension (K9g):** a group
sent with `suspended: true` keeps its offer and rule and is written
`inactive` in place; sent again without it, the same id is `active` again.
Suspending a group already offered with the same rule is ungated like a
removal; resuming it, or suspending it with a changed rule, is gated, and a
resume (like any offer becoming `active`, an opt-in included) needs an
active group (`422 · catalog.modifier_group_not_active`) and a valid rule.
**Per-variant lists (K9g; one route since API v7 PR 5):** each entry of
`variantOverrides` (the former `PUT
/catalog/items/{itemId}/variants/{variantId}/modifier-groups`) takes the
same list plus `optedOut` per group and writes the variant scope with the
same identity rules (update in place by `(item_variant_id,
modifier_group_id)`, retire what the list leaves out, revive by the same
key). `optedOut: true` writes an `inactive` variant row whose rule and
defaults are ignored and stored as `0`, `1` and none; a group left out
inherits the item offer again. The variant must belong to the item (else
`404 · catalog.not_found`, like an unknown or foreign one) and not be
archived (`422 · catalog.variant_archived`), both pointing at
`variantOverrides[i].variantId`; an inactive variant may be
configured. Every refusal of a rule points at its offer, for example
`variantOverrides[1].offers[0].minSelections`, and every changed list is
judged and written in the same transaction or none is. `If-Match` is the item's ETag, which covers the variant offers.
An opt-out or a drop is never gated; an override, an opt-in or a reorder
is. The record is the same `catalog.item.modifier_groups_set` on the item,
with `catalog.item.variant` (the variant id) and
`catalog.item.variant.modifier_groups` (the lists) instead of
`catalog.item.modifier_groups`, so no audit action type is added.
**Audit:** `catalog.item.modifier_groups_set` (before/after lists, each
offer as `offerId:groupId:min:max:status`, then its sorted defaults; the
after list ends with the offers this change retired).
**Errors:** `422 · catalog.selection_rule_invalid` (*new*),
`422 · catalog.default_options_invalid` (hardening round).
**Scenario rows:** required group (min 1, max 1); optional multi-select
(min 0, max 3); min greater than the units the available options reach →
422; one option of `maxQuantity` 2 offered with min 2, max 2 → 200 (F1);
group deactivated
after being offered → C17 omits it and, if it was required, omits **the
item** from C17 with a warning counter in `meta` (*Decided*: never sell
something that cannot be configured validly).

### 5.6 Business-unit assortment

#### C14 — Choose how a business unit's assortment works

**Actor:** `catalog.assortment.manage` **at the business unit** (a manager
of one business unit can do this); capability `catalog.items`.
**Route:** `PUT /catalog/business-units/{businessUnitId}/assortment` with
`{ mode: 'all' | 'selected' | 'none' }`.

* `all`: sells every active item of the default catalog except explicit
  exclusions (C15). Same as having no row, which is the SQL default for an
  `organization` access-mode catalog (`variant_is_listed`: `g.id IS NULL AND
  c.access_mode = 'organization'`). Since revision 109 (D1) every business
  unit receives a row when it is registered, and migration `0099` gave every
  earlier unit an explicit active `all` row, so "no row" is left only for
  catalogs other than the default one.
* `selected`: sells only items explicitly listed.
* `none`: the business unit sells nothing from this catalog
  (`business_unit_catalogs.status = 'inactive'`).
  **Main path:** lock the catalog version row (lock order, §5) → upsert the
  `business_unit_catalogs` row (`guard_assortment_change` then locks the
  business unit row, 4460) → audit
  `catalog.assortment.mode_changed` (resource kind `catalog.assortment`).
  **Statuses:** `200`; same mode → `200`, nothing written.
  **Scenario rows:** BU manager of BU-A on BU-A → 200, on BU-B → 403;
  inactive BU → 422 `authorization.place_not_operational`; switching `all`
  → `selected` with no listings empties the POS (documented, and the response
  carries `listedCount: 0` so the UI can warn).

#### C15 — List or delist items at a business unit

**Actor/permission:** as C14 (listing gated; delisting **ungated**).
**Route:** `POST /catalog/business-units/{businessUnitId}/assortment/listing-changes`,
body `{ list?: [...], delist?: [...] }`, up to 200 ids in each list, at
least one id in all, never the same id in both (API v7 PR 7; until then two
routes, `.../listings` and `.../delistings`, with `{ itemIds }`). The plan
gates the change only when `list` names an item.
**Semantics (*Decided*, making the SQL's dual meaning explicit):** in
`selected` mode a listing is an active `business_unit_items` row (an
inclusion); in `all` mode a delisting writes an inactive row (an
exclusion), because `variant_is_listed` already treats `(bi.id IS NULL OR
bi.status = 'active')` that way. The API speaks only of "listed / not
listed"; the repository translates to the row state for the current mode.
**Concurrency:** the catalog version row is locked first (lock order,
§5), which serialises concurrent calls of the organization; then one
set-based upsert, whose trigger also locks the business unit row. Items are
locked `FOR SHARE` in id order after the version row.
**Audit:** one `catalog.assortment.items_listed` record for the items that
started selling and one `.items_delisted` record for the ones that stopped,
each only when its direction changed something, with the id list as the
change (capped at 200 each, so bounded).
**Errors:** `404 · catalog.not_found` (any unknown id: the whole call fails,
nothing written), `422 · catalog.item_archived` when listing an archived
item (as built in K6: an inactive item may be listed and sells once it is
reactivated, so only archival refuses; the code first sketched here,
`catalog.item_not_active`, was never built).
**Scenario rows:** list 200 ids → 200; 201 ids → 400; same id twice → 400;
the same id in `list` and `delist` → 400 pointing at `delist[i]`;
mode `none` → `422 · catalog.assortment_disabled` (*new*, a state refusal:
the catalog keeps `409` for uniqueness clashes); delist works on a
downgraded plan; concurrent list and mode change → serialised by the
catalog version row, which both take first.

#### C16 — Read a business unit's assortment

**Actor:** `catalog.items.view` or `catalog.assortment.manage` at the BU.
**Route:** this is C2 with `businessUnitId` and `listed=true|false`; no
separate route (*Decided*, merged).

### 5.7 Point-of-sale reads (the hot path)

#### C17 — Read what an outlet sells

**Actor:** `catalog.sellables.view` **at the outlet** (cashier, waiter,
sales associate, manager). Not capability-gated (if the catalog is empty
the answer is empty).
**Route:** `GET /catalog/outlets/{outletId}/sellable-items`.
**Main path (one tenant transaction, read-only):**

1. Permission at the outlet (`PermissionCheckContract`, 1 statement).
2. Resolve the effective business unit on the site's date and read
   `catalog_versions.version` and `organization.structure_versions.version`
   (1 statement). Compute a strong ETag
   `"c<catalogVersion>-s<structureVersion>-<businessUnitId>"` (`-none` when
   no business unit operates the outlet today). *Decided*: the server emits
   a strong tag, because the two versions name exactly one body, as a
   strong tag requires (the envelope carries no request id or timestamp).
   The foundation compares as RFC 9110 prescribes: `If-None-Match` uses the
   weak comparison, so `W/"x"` matches `"x"` and answers `304`
   (`satisfiesIfNoneMatch` in
   `src/foundation/http/conditional-requests/conditional-request.ts`, which
   the envelope interceptor and the K7 early `304` share), and
   `If-Match` uses the strong comparison, so a weak tag there answers `412`
   (`src/foundation/http/conditional-requests/conditional-request.ts`).
   If `If-None-Match` matches → `304`, empty body, no further SQL.
3. Otherwise read, set-based, through `catalog.listed_items(org, bu)` (G4):
   active items with active variants (statement A), their primary active
   category ids (in A), offered active modifier groups with active options
   (statement B), the category tree (statement C).
   **K7 requirement (owner decision 2026-10-05, rule 9 of "Category
   lifecycle and effective visibility"):** the tree omits every category
   that is not effectively active, which hides a whole branch below an
   inactive or archived ancestor without changing any descendant; an item
   whose primary category is not effectively active is shown under
   "Other", never dropped. No category condition is added to sellability
   (rule 8).
   **K7 requirement (K5 design note):** when it judges whether an item's
   selection rule can be satisfied (`minSelections` ≤ the sum of the
   `maxQuantity` of the available options since F1; the count of available
   options before),
   the read counts as unavailable every option that is not active, whose
   group is not active, or whose `catalog_variant` target variant is not
   active (inactive or archived) or belongs to an item that is not active
   (since K8: an extra that sells an archived or paused item is not offered;
   since K16b also an extra whose item the unit does not list or the outlet
   hides, or a combo that cannot be composed: the target is judged like the
   component of a combo); an item with a required group whose rule
   is unsatisfiable that way is omitted (C13). The K5 writes do not re-check
   it when an option, a group or a target variant changes later, by design.
   **K7 requirement (hardening round, owner decision):** the point of sale
   preselects exactly the `defaultOptionIds` of the item's offer of each
   group, never a property of the option; since retiring an option, its
   group or its target variant removes it from every offer's defaults in
   the same transaction, every default C17 reads is available.
   **Response (compact by design):**

```text
data: {
  businessUnitId, catalogVersion,
  categories: [{ id, parentId, name, sortOrder }],
  items: [{ id, name, categoryId, variants: [{ id, name, isDefault,
            salesUnitCode, minimumSalesQuantity, salesQuantityStep,
            maximumSalesQuantity }],
            modifierGroups: [{ id, minSelections, maxSelections,
            defaultOptionIds }] }],
  modifierGroups: [{ id, name, options: [{ id, name, kind,
            variantId, maxQuantity }] }],   // de-duplicated
  truncated: boolean
}
```

No descriptions, codes, SKUs or barcodes (payload budget); prices are added
by composition when Pricing ships (the POS reads `sellable-items` and the
price list for the same `catalogVersion`).
**Bounds:** *Decided* at most 2,000 items per response; above that
`truncated: true` and the client falls back to C19 (paged search of what the
outlet sells) and C18 (barcode), both authorized with `catalog.sellables.view`
at the outlet, so a `cashier` or `waiter` never needs `catalog.items.view`
or access to the item master (C2). A retail store with 20,000 SKUs is a
search-and-scan POS, not a scroll POS.
**Outlet with no business unit today:** `organization.effective_business_unit`
returns `NULL` when no active assignment covers the outlet on its site's
date (`0020_organization.sql`, function comment). The permission step runs
first: a member whose scope reaches the outlet only through a business unit
or a legal entity is not covered (no unit resolves) and gets the shared
`403` refusal, while an organization-wide, brand, site or outlet scope still
passes. A caller that passes gets `200` with `businessUnitId: null`, empty
`categories`, `items` and `modifierGroups`, `truncated: false`, and the
ETag ending in `-none`; never `404` or `500`. An inactive outlet keeps the
shared refusal rows (`scope_unavailable` or `outlet_not_operational`).
**Caching:** `ETag` + `Cache-Control: private, no-cache` (tenant data, D24).
**Latency:** `AGENTS.md` §5 targets p50 < 15 ms, p90 < 35 ms, p95 < 50 ms,
p99 < 100 ms. The `304` path is 3 statements on primary-key lookups and
should sit far below p50. The `200` path for a representative restaurant
(300 items, 2 variants, 10 groups × 6 options) must be measured; enters
`perf:http` (§8.4).
**Scenario rows:** happy path restaurant; `selected` assortment; `none`
mode → empty; inactive item absent; inactive variant absent while the item
remains (if it has another active variant); item whose only variant is
inactive absent; required modifier group inactive → item absent (C13);
outlet reassigned to another business unit today → different list and
ETag; outlet assignment effective tomorrow → today's list; 304 after no
change; ETag changes after an unrelated item edit (accepted cost of an
organization-wide version, see §11); cashier scoped to outlet A on outlet B
→ 403; inactive outlet → 403 `authorization.scope_unavailable` or 422
depending on the assignment (as `PermissionCheckContract.decide`); 2,001
items → `truncated`; outlet with no business unit assignment today →
organization-wide caller `200` with empty lists and `businessUnitId: null`,
business-unit-scoped caller `403`; a strong ETag is emitted, and an
`If-None-Match` carrying its weak form `W/"…"` still answers `304` (weak
comparison, RFC 9110), while a weak tag in `If-Match` answers `412`.
**Per variant (K9f, [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.2):**
the required groups are judged on the effective offers of each active
variant (an active variant offer replaces the item offer, an inactive one
opts the variant out, a missing or retired one inherits, a suspended item
offer that is required makes the variant not configurable); `variants[]`
keeps only configurable variants, an item is listed while one is, and
`omittedItems` counts an item only when none is. The item-level
`modifierGroups` are unchanged. **K9g** adds the per-variant delta of §5.2
of that design: a listed variant with a live variant row carries
`modifierGroupOverrides: [{ modifierGroupId, optedOut, minSelections,
maxSelections, defaultOptionIds }]`, an opt-out always and an active
override while its group has an available option, in the order of the
rows; a variant without one carries no key at all, so an item without
overrides adds zero bytes. A group only an active variant row offers joins
the top-level `modifierGroups`. The same delta is in C19. Same statements,
same ETag: the catalog version already moves on every offer write.
**Images (A6, AC4 of [`ASSETS_USE_CASES.md`](ASSETS_USE_CASES.md) §7.5):**
every item carries `imageRef` (`<delivery token>/p<profile>`, or `null`)
and `aiGenerated`; a variant carries both only when it has a primary image
of its own, otherwise no key at all and the till shows the item image;
`meta.media.tileUrlTemplate` turns a reference into the tile URL. The read
statement left-joins `catalog.item_assets` through the one-primary indexes
and one `AssetDeliveryContract.describe` call (only when some listed item or
variant has an image) resolves the references, so the `200` costs 9
statements with images and 8 without; the ETag gains `-d<delivery
fingerprint>` (`"c<catalogVersion>-s<structureVersion>-<businessUnitId|none>-d<fingerprint>"`),
computed without SQL, so the `304` stays at 7. C19 adds the same fields per
page. As built in "A6 as built" of `ASSETS_USE_CASES.md`.
**Menus (M2b):** the answer carries a top-level `menus` array, the active
menus the outlet shows (C29) in its order, by ids only: sections and
subsections that are not suspended, and the entries whose item is in the same
sellable set; `variantIds` only on an entry that shows selected variants.
Menus only organize; an outlet without menus answers `menus: []`. Same
statements, same tag. As built in "M2b as built" below; C19 and C18 do not
carry menus.
**Combos (B2c):** a cashier holds `catalog.sellables.view`, never
`catalog.items.view`, so the back-office read of a combo (B1) is closed to
it; C17 therefore carries the composition. An item with at least one active
group carries `bundleGroups`: its active groups in the order the customer
sees them, each with only the choices `admit` accepts now at this outlet,
using the field names of B1 (`bundleGroupId`, `name`, `minSelections`,
`maxSelections`, and per choice `choiceId`, `itemId`, `variantId`,
`quantityPerSelection`, `maxSelectionCount`, `isDefault`). A plain item has
no key at all, byte for byte as before. Never sent: a suspended group, a
suspended choice, a choice whose item or variant is not active, is not listed
at the unit, needs a required modifier group it cannot fill, or is a combo
that cannot be completed there (F1b, F1c), nor an optional group left without
a choice; a combo whose required group cannot be filled is not sold at all
(B2b), so `bundleGroups` can be `[]` only for a combo whose optional groups
have nothing to offer now. A choice names its item by reference: that item
is itself in `items`, with its variants, its modifier groups and, for a
nested combo, its own `bundleGroups`.

```json
{
  "itemId": "01930000-0000-7000-8000-0000000000b0",
  "name": "Combo hamburguesa",
  "categoryId": null,
  "imageRef": null,
  "aiGenerated": false,
  "variants": [{ "variantId": "01930000-0000-7000-8000-0000000000b9",
    "name": null, "isDefault": true, "salesUnitCode": "unit",
    "minimumSalesQuantity": "1", "salesQuantityStep": "1",
    "maximumSalesQuantity": null }],
  "modifierGroups": [],
  "bundleGroups": [
    { "bundleGroupId": "01930000-0000-7000-8000-0000000000b1",
      "name": "Principal", "minSelections": 1, "maxSelections": 1,
      "choices": [{ "choiceId": "01930000-0000-7000-8000-0000000000c1",
        "itemId": "01930000-0000-7000-8000-0000000000a1",
        "variantId": "01930000-0000-7000-8000-0000000000a2",
        "quantityPerSelection": "1", "maxSelectionCount": null,
        "isDefault": true }] },
    { "bundleGroupId": "01930000-0000-7000-8000-0000000000b3",
      "name": "Acompañamiento", "minSelections": 0, "maxSelections": 2,
      "choices": [{ "choiceId": "01930000-0000-7000-8000-0000000000c4",
        "itemId": "01930000-0000-7000-8000-0000000000a7",
        "variantId": "01930000-0000-7000-8000-0000000000a8",
        "quantityPerSelection": "1", "maxSelectionCount": 1,
        "isDefault": false }] }
  ]
}
```

A choice is judged where it sits in the combo the till reads: a combo of
`items` judges its choices as the first combo level. Two limits follow and
the till applies them:

* **Depth.** A sale holds at most three combo levels (F1b). The choices of a
  combo met as a component are those it offers when sold on its own, so
  inside the third level the till offers only choices whose item carries no
  `bundleGroups` key (a key with `[]` still counts: that combo has an active
  group); `admit` refuses a deeper selection (`too_deep` request problem, or
  `bundle_too_deep` when the till sends no selection for it).
* **Truncation and search.** The item of a choice is always sellable at the
  outlet, so it is in a complete C17; in a read cut at 2,000 items, or on a
  C19 page, it may be missing and the till finds it by searching.
* **Defaults.** For a group the till does not send, `admit` applies its
  default choices while the choice and its component are active, even one
  C17 hides for its listing or its modifiers, and then refuses the line for
  it; so the till sends every group it shows with the choices the customer
  kept.

The composition is one more column of the same read statement, so the
statement counts and the ETag do not change: every write of a group, a
choice, a component, a listing or a modifier moves the catalog version.
As built in "B2c as built".

#### C18 — Look up a barcode at an outlet

**Actor:** `catalog.sellables.view` at the outlet.
**Route:** `GET /catalog/outlets/{outletId}/barcode-matches?barcode=`.
**Main path:** effective business unit → `catalog.resolve_barcode(org, bu,
barcode)` (4528), which already filters by the listing rule and raises when
ambiguous.
**Response:** `200` with `{ itemId, variantId, name, variantName,
salesUnitCode, quantity policy }`; no match → `200` with `data: null`
(*Decided*: "not found" at a till is a normal outcome, not an error, and a
404 would be logged as noise, D14). Ambiguous across catalogs → `409 ·
catalog.barcode_ambiguous` (*new*; maps `catalog_barcode_ambiguous`,
SQLSTATE 21000). Not ETagged (per-request, tiny).
**Note:** `resolve_barcode` takes `FOR SHARE` locks (4504–4526); for a pure
read this is heavier than needed. *Decided*: C18 uses a read-only twin
query without locks; the locking function stays for `admit` (C20).
**Scenario rows:** scan listed item; scan delisted item → null; scan
suspended or archived barcode → null; same barcode in another organization → null;
latency in `perf:http`; since K9f a variant that is not configurable under
its own effective offers → null, while the other variants of the item still
resolve.

#### C19 — Search what an outlet sells

**Actor:** `catalog.sellables.view` **at the outlet** (cashier, waiter,
sales associate, manager), exactly as C17 and C18. Never `catalog.items.view`:
the till searches what it may sell, not the item master. Not
capability-gated.
**Intent:** the fallback of C17 when the outlet sells more than 2,000 items
(`truncated: true`), and the ordinary "type a few letters" search of a
retail POS.
**Route:** `GET /catalog/outlets/{outletId}/sellable-item-search?q=&barcode=&cursor=&limit=`.

* `q`: prefix match on the item or variant name, case-insensitive, 1–80
  characters, `%` and `_` escaped (same rule as C2, §8.3).
* `barcode`: exact match on an active barcode of an active variant; `q` and
  `barcode` are mutually exclusive and one of them is required (`400`
  otherwise).
* Keyset order `(lower(name) COLLATE "C", id)`, `limit` default 50, max 100,
  signed opaque cursor bound to the outlet, the business unit resolved for
  it and the filter (`collection.cursor_incompatible` when any differs).
  **Main path (one tenant transaction, read-only):** permission at the outlet
  (1) → effective business unit on the site's date (1) → one set-based page
  query through `catalog.listed_items(org, bu)` (G4): only items listed for
  that business unit with at least one active variant, so not-sellable items
  (inactive, archived, delisted, or with no active variant) never appear; an
  item whose required modifier group cannot be satisfied by its available
  options (judged exactly as in C17: an inactive option, an inactive group or
  an inactive or archived target variant, or one whose item is not active,
  counts as unavailable, and since K16b one whose item the unit delists or the
  outlet hides) does not
  appear either.
  **Response:** the item shape of C17 (`id, name, categoryId, variants[],
  modifierGroupIds[]`) plus `meta.pageInfo`; `categoryId` follows the same
  K7 rule as C17 (a category that is not effectively active is not offered as
  navigation, and the item is never dropped for it); no codes, SKUs, descriptions or
  barcodes beyond the one matched. Not ETagged (a cursor page, D24). Since B2c
  a combo carries the same `bundleGroups` as in C17; the item of a choice may
  sit on another page.
  **Outlet with no business unit today:** as C17: the permission step decides
  first; a caller that passes gets `200` with an empty page.
  **Scenario rows:** outlet with 2,001 items, C17 answers `truncated: true`
  and C19 pages through all of them for a `cashier` and for a `waiter` that
  hold only `catalog.sellables.view`; the same `cashier` gets `403` from C2
  (no `catalog.items.view`); name prefix search, case- and accent-variant
  input; barcode exact match and a barcode of a delisted item → empty page;
  cursor stability under a concurrent item insert (no duplicate, no skip);
  a cursor from another outlet or another `q` → `400 ·
  collection.cursor_incompatible`; inactive item, archived item, item whose
  only variant is inactive and item delisted at the unit absent; `q` and
  `barcode` together → `400`; `limit=101` → `400`; cashier scoped to outlet A
  searching outlet B → `403`; tenant isolation with identical names in two
  organizations. Since K9f the page keeps only the configurable variants of
  each item, judged exactly as C17.

### 5.8 Internal contract

#### C20 — Resolve and admit a sellable variant (Pricing, Sales)

**No HTTP route.** `CatalogSellableContract` (D18, read and synchronous
modes):

* `resolve(ctx, { businessUnitId, variantIds[] })` → snapshots
  `{ variantId, itemId, itemName, variantName, salesUnitCode, quantity
  policy, sellable: boolean }` for Pricing configuration screens and Sales
  draft building; one set-based statement.
* `admit(ctx, { businessUnitId, lines: [{ variantId, quantity, selections: [{ optionId, quantity }] }] })` (as built since K9d; K8 took
  `modifierOptionIds[]`) → runs
  `catalog.assert_variant_listed` (4504) inside the caller's tenant
  transaction (as built, K8 reads the rule set-based instead and never calls
  it; see "K8 as built") (it takes `FOR SHARE` on the business unit, variant, item and
  catalog, so a concurrent archival cannot slip between admission and the
  sale insert) and validates modifier options against the item's offer and
  selection rules. Throws `CatalogNotSellableError` (Sales maps it to its own
  public code).
  **Scenario rows:** admitted variant; archived between `resolve` and `admit`
  → refused; concurrent archival blocks until the sale commits (FOR SHARE vs
  FOR UPDATE); option not offered on the item → refused; min/max violated →
  refused; foreign variant → refused without leaking existence. Since K9d
  ([`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.4): one option chosen
  twice → admitted with `quantity: 2`; a group's min/max judged on the sum of
  units; an option above its `maxQuantity` → `option_quantity_above_maximum`;
  a line quantity off the variant's sales policy → `quantity_out_of_policy`;
  an option quantity outside 1–999 or not an integer, or a malformed line
  quantity → `invalid_quantity` before any SQL. Since K9e
  ([`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §5.1): `resolve` answers
  the effective offers of each variant inside its snapshot
  (`itemModifierGroupId`, `modifierGroupId`, `source`, min, max, the options
  with the sales unit of each target variant), and `admit` answers each line
  with its `lineIndex` and its effective offers, each holding the line's
  selections with everything a Sales modifier row stores and the removed and
  reduced defaults; the rows built from that answer alone pass the real Sales
  modifier trigger and group check. Since K9f
  ([`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.2): the offers are the
  variant's effective offers, `source: variant` with the override's id when an
  active variant offer applies, none for a group the variant opted out of (its
  options answer `option_not_offered`), and a variant whose effective required
  groups cannot be met answers `required_modifier_unsatisfiable` while its
  siblings are admitted. Since migration `0090` (X13 closed) Sales agrees:
  a line of an opted-out variant without selections, admitted here, passes
  the Sales group check, and a selection through the item offer on that
  variant is refused as not offered.

### 5.9 Status of the later cases

Current status of the cases that were deferred when this design was written
(Catalog = 54 operations, OpenAPI 13.10.0, baseline revision 124). The routes
named here for the deferred cases are design sketches, not built.

| Case | Status | Route or reference |
|---|---|---|
| C21 Menus and outlet menu assignment | Built: M1 structure ("M1 as built"), M2a assignment (C29, "M2a as built"), M2b till read by menu | `GET`, `POST /catalog/menus`, `GET`, `PATCH /catalog/menus/{id}`, `PUT .../structure`, `POST .../{activation,archival}`; `GET`, `PUT /catalog/outlets/{id}/menus`; `menus` in the sellables read |
| C30 Items an outlet does not sell | Built: K16 ("K16 as built") | `GET`, `PUT /catalog/outlets/{id}/hidden-items` |
| C32 Variant dimensions | Built: K13 ("K13 as built"): the matrix writes them, the item read shows them, the list offers them as filters | `GET /catalog/variant-dimensions`; `variantValue` in `GET /catalog/items` |
| C22 Bundles / combos | Built: B1 composition, B2 admission (C28), B2c `bundleGroups` in the till and the search; B3 waits for Pricing and Sales | `GET`, `PUT /catalog/items/{id}/bundle-groups` |
| C26 Images | Built | `PUT /catalog/items/{id}/images`, `PUT /catalog/item-images` |
| S1 Menu time windows | Built: C31 ("S1 as built"); holidays and date ranges deferred | `windows` in `PUT /catalog/outlets/{id}/menus`; `menusValidUntil` in the sellables read |
| C23 Translations | Deferred: content-locale negotiation | `PUT /catalog/items/{id}/translations/{locale}` (design sketch) |
| C24 Food declarations | Deferred: `catalog_food` boundary decision | `PUT /catalog/items/{id}/allergens` (design sketch) |
| C25 Additional catalogs | Deferred: OD4 | `POST /catalog/catalogs`, assign to business units (design sketch) |
| C27 Import | Deferred: its own design | `POST /catalog/imports` (design sketch) |

### 5.10 Derived API

| Group | Routes | Cases |
|---|---|---|
| Workspace and reads | `GET /catalog/workspace`, `GET /catalog/items`, `GET /catalog/items/{id}`, `GET /catalog/categories`, `GET /catalog/categories/{id}` | C1, C2, C3, C9, C10, C16 |
| Items | `POST /catalog/items`, `PATCH /catalog/items/{id}`, `POST /catalog/items/{id}/{deactivation,reactivation,archival,restoration}`, `PUT /catalog/items/{id}/categories`, `PUT /catalog/items/{id}/modifier-groups` | C4, C5, C7, C8, C13 |
| Variants and barcodes | `POST /catalog/items/{id}/variants`, `PATCH .../variants/{vid}`, `POST .../variants/{vid}/default-selection`, `POST .../variants/{vid}/{deactivation,reactivation,archival}`, `PUT .../variants/{vid}/barcodes` (the set, API v7 PR 4) | C6, C11 |
| Categories | `POST /catalog/categories`, `PATCH /catalog/categories/{id}` (moves too since API v7 PR 3), `POST /catalog/categories/{id}/transitions` | C9 |
| Modifiers | `POST /catalog/modifier-groups`, `PATCH`, options, lifecycle | C12 |
| Images | `PUT /catalog/items/{id}/images` with `images` and optional `variants`, `PUT /catalog/item-images` with an optional `ifMatch` per entry (Assets slices A5b and A5c, `ASSETS_USE_CASES.md` §7.5; merged in API v7 PR 4) | AC1, AC2, AC3, AC5 |
| Combos | `GET /catalog/items/{id}/bundle-groups`, `PUT /catalog/items/{id}/bundle-groups` (B1) | C22 |
| Menus | `GET /catalog/menus`, `POST /catalog/menus`, `GET /catalog/menus/{id}`, `PATCH /catalog/menus/{id}`, `PUT /catalog/menus/{id}/structure`, `POST /catalog/menus/{id}/{activation,archival}` (M1) | C21 |
| Assortment | `PUT /catalog/business-units/{id}/assortment`, `POST .../assortment/{listings,delistings}` | C14, C15 |
| Outlet menus | `GET /catalog/outlets/{id}/menus`, `PUT /catalog/outlets/{id}/menus` (M2a; the windows of S1) | C29, C31 |
| Outlet hidden items | `GET /catalog/outlets/{id}/hidden-items`, `PUT /catalog/outlets/{id}/hidden-items` (K16) | C30 |
| POS | `GET /catalog/outlets/{id}/sellable-items`, `GET /catalog/outlets/{id}/barcode-matches`, `GET /catalog/outlets/{id}/sellable-item-search` | C17, C18, C19 |
| Internal | `CatalogSellableContract` | C20 |

*Decided:* routes carry the `/catalog` prefix (Organization routes do not)
because `items`, `categories` and `variants` are words other modules
(inventory, pricing) will also use; the prefix keeps OpenAPI tags and
throttling per module unambiguous.

***

## 6. Vertical variations

### 6.1 Common vs vertical-specific

| Concept | Restaurant | Retail | Hospitality | Where it lives |
|---|---|---|---|---|
| Item + default variant | Dish, drink | Product | Service/extra (laundry, minibar) | Catalog, common |
| Named variants | Sizes (small/large), presentations | Size, colour, pack | Rarely | Catalog, common |
| SKU | Optional | Usually | Optional | Catalog, common |
| Barcodes | Bottled drinks only | Core | Minibar | Catalog, common |
| Sales unit and step | `unit` | `unit`, `kg` (step 0.050) | `unit` | Catalog, common (`item_variants`) |
| Categories | Starters, mains, drinks | Departments | Service families | Catalog, common |
| Modifiers | Core (cooking point, extras, sides) | Rare (gift wrap, engraving) | Room-service dishes | Catalog, common |
| Bundles/combos | Combo meals | Kits | Packages | Catalog later + Pricing |
| Menus, sections, dayparts | Core later | Not needed | Room-service menu | Catalog later (G1) |
| Course, prep station, routing | Yes | — | — | Kitchen (vertical) |
| Allergens, nutrition | Yes | Food retail | Room service | `catalog_food` (proposed) |
| Rooms, room types | — | — | Yes | Hospitality (Phase 10), **not** catalog |
| Stock tracking of an item | Optional | Yes | Minibar | Inventory (`catalog_stock_links`) |

Two more businesses were weighed on 2026-10-05 (owner decision: design only,
nothing in the database, no vertical code):

| Business | What the catalog gives it | What is NOT catalog |
|---|---|---|
| Gym | A membership, a pass or a single class is a sellable item with its default variant (a service) | Renewal, validity, class capacity and access control: modules that do not exist yet |
| Clothing (apparel) | Technically retail: products, SKUs, barcodes; the size × colour matrix is a catalog capability delivered with K4 (variants), shared with footwear and any variant-heavy business | Seasons and collections, exchanges and returns, stock per size: other modules. No separate vertical code unless its roles or screens differ from retail |

**Conclusion (*Decided*):** the catalog has no vertical-specific table in the
first delivery. Every concept above in the "Catalog" column is shared. This
matches `ROADMAP.md` Phase 8 ("The restaurant vertical should not duplicate
Catalog") and R8.

### 6.2 How business-unit verticals apply

`platform.business_unit_verticals` (activated by O11) does **not** gate
catalog commands: catalog commands pass no `p_vertical_code` (*Decided*;
a core module never asks "is this a restaurant", `ARCHITECTURE.md` §14).
Verticals act in two places only:

1. **UX:** the client reads the verticals of the selected business unit from
   `GET /session/context` (`workspace.businessUnits[].verticals`) and shows
   modifiers-first screens for restaurant and barcode-first screens for
   retail. The server accepts the same commands from both.
2. **Roles:** the vertical roles of D30 (`waiter`, `kitchen`, `bar`,
   `sales_associate`, `stock_keeper`) receive catalog permissions (§7.2);
   the D30 offer rule already prevents offering `waiter` where restaurant is
   inactive.

Vertical modules (kitchen, restaurant service) will consume
`CatalogSellableContract` and add their own facts by FK to
`catalog.item_variants` / `categories`, never by writing catalog tables (R5).

***

## 7. Authorization design

### 7.1 Permission codes

Format `module.resource.action` (three segments required by
`permissions_code_chk`, `0040_identity.sql`).

| Code | Grants | Anchor checked | Capability gate |
|---|---|---|---|
| `catalog.items.manage` | Create and edit items, variants, barcodes, categories, modifier groups, combos and menus; lifecycle of all of them | Organization-wide only (v1; OD3) | `catalog.items` for create/edit/reactivate; none for deactivate/archive |
| `catalog.items.view` | Back-office reads C1–C3, C10, C16, C21 (menus), C22 (combos) | Organization-wide, or a business unit (with `businessUnitId`) | none |
| `catalog.assortment.manage` | C14, C15 | Business unit (`authorize_operation_at('business_unit')`); an organization-wide scope also satisfies it | `catalog.items` for listing and mode changes; none for delisting |
| `catalog.sellables.view` | C10, C17, C18, C19 | Outlet (`PermissionCheckContract` with `outletId`, which also accepts broader scopes covering that outlet) | none |

*Decided:* no `catalog.items.create` / `.update` split. Owners and managers
asked for "who can edit the products"; splitting create from edit is a
distinction no current role uses (D30 rejects ranks expressed outside
permissions; a split can be added later without breaking codes).

### 7.2 Seeded role mapping (proposal, per D30)

D30 says each module adds its permissions to the seeded roles in its own
migration and also to `organization_owner` (containment rule of D29).

| Role (`vertical_code`) | `catalog.items.manage` | `catalog.items.view` | `catalog.assortment.manage` | `catalog.sellables.view` |
|---|---|---|---|---|
| `organization_owner` (—) | ✓ | ✓ | ✓ | ✓ |
| `manager` (—) | ✓ (effective only with an organization-wide assignment) | ✓ | ✓ | ✓ |
| `cashier` (—) | | | | ✓ |
| `waiter` (restaurant) | | | | ✓ |
| `kitchen` (restaurant) | | | | |
| `bar` (restaurant) | | | | |
| `sales_associate` (retail) | | ✓ | | ✓ |
| `stock_keeper` (retail) | | ✓ | | |

Reasoning: a `manager` assigned to one business unit gets
`catalog.items.manage` in its role but `effective_permission` with no outlet
requires an organization-wide scope, so it can curate its unit's assortment
(C14/C15 at the BU anchor) without editing the shared master data that other
units sell; the same role assigned organization-wide edits everything. This
is exactly the "catalog manager scoped to one business unit" that
`identity.effective_permission_at` was built for (the test fixture already
uses `test.catalog.manage`, `database/tests/shared/anchored-permission-fixture.ts:25`).
Kitchen and bar receive nothing: they read item names on kitchen tickets
through the Kitchen module, not the catalog. `sales_associate` needs search
to answer "do we have it in blue?". `stock_keeper` needs lookup for
receiving (Inventory, later).

### 7.3 Capability gating

*Decided:* one capability, `catalog.items` ("Products and catalog"), seeded
into `platform.capabilities` and attached to a new active plan version of
`pionero` (the pattern of migration `0071`: create version N+1, copy
capabilities and limits, activate, publish offerings).

| Never gated (repair and read) | Gated by `catalog.items` |
|---|---|
| All reads (C1–C3, C10, C16–C19) | Create/edit items, variants, barcodes, categories, groups (C4–C6, C8, C9, C11–C13) |
| Deactivate / archive anything (C6, C7, C9, C11, C12) | Reactivate / restore (puts something back on sale) |
| Detach modifier groups from an item (C13, a list that only removes) | Attach a group or change a selection rule (C13) |
| Delist (C15 delistings) | Assortment mode, listings (C14, C15 listings) |
| `CatalogSellableContract` | — |

Order of refusal is the one `platform.authorize_operation` already
guarantees: a caller without the permission never learns about the plan
(`0071` function comment).

***

## 8. Performance and data design

### 8.1 Expected cardinalities (assumption, to validate with real tenants)

| Profile | Items | Variants | Barcodes | Categories | Modifier groups / options | Business units |
|---|---|---|---|---|---|---|
| Small restaurant | 50–300 | 1.2× items | ~0.1× | 5–20 | 5–30 / 20–200 | 1 |
| Restaurant group | 300–1,500 | 1.5× | ~0.2× | 20–60 | 30–150 / 200–1,000 | 2–10 |
| Small retail | 500–5,000 | 2× | 1–1.5× variants | 10–100 | 0–5 | 1 |
| Larger retail | 5,000–50,000 | 2–3× | 1–2× | 100–500 | 0–10 | 1–20 |
| Hotel | 20–300 services + F\&B unit as restaurant | 1× | rare | 5–30 | as restaurant | 2–4 |

All master tables grow with configuration, not with traffic; none grows
without bound per day. `business_unit_items` is the largest multiplier
(items × units in `selected` mode): 50,000 × 20 = 1M rows in the worst case.

### 8.2 Hot queries and indexes

| Query | Existing index | Needed (new migration) |
|---|---|---|
| C2 list by name, status, keyset | `catalog_items_organization_status_idx (organization_id, status, name)` (123) — no `catalog_id`, no `id` tiebreak, no `lower()` | `items (organization_id, catalog_id, status, lower(name) COLLATE "C", id)`; replaces the old one only after `EXPLAIN (ANALYZE, BUFFERS)` proves it unused |
| C2 prefix on name | none usable | covered by the index above (`COLLATE "C"` makes `LIKE 'abc%'` indexable) |
| C2 prefix on code | `catalog_items_code_ci_uq (organization_id, catalog_id, lower(code))` (4426) | a `text_pattern_ops` twin only if the plan shows the unique index is not used for prefix |
| C2 prefix / exact on SKU | `item_variants_sku_ci_uq` (4430) | same remark |
| C17 variants of listed items | `item_variants_item_status_idx (organization_id, item_id, status)` (255) | none |
| C17 listing in `selected` mode | `business_unit_items UNIQUE(business_unit_id, item_id)` | none |
| C17 offered groups | `item_modifier_groups_item_idx` (2027), `modifier_options_group_status_idx` (1875) | none |
| C17 primary category | `item_categories_one_primary_uq` (471) | none |
| C18 barcode → variant | `item_variant_barcodes_value_uq (organization_id, catalog_id, barcode)` (4431) | with one catalog this is an exact lookup; when OD4 brings several catalogs, add `(organization_id, barcode)` |
| C19 page of listed items by name prefix | none for the listed-and-name order | the C2 index `items (organization_id, catalog_id, status, lower(name) COLLATE "C", id)` drives the order; listing joins `business_unit_items UNIQUE(business_unit_id, item_id)`; `EXPLAIN (ANALYZE, BUFFERS)` on the "larger retail" profile decides whether a dedicated index is needed |
| C19 barcode | `item_variant_barcodes_value_uq` (4431) | none |
| `catalog_versions` read | PK (G2) | — |

Every new query on `items`, `item_variants` and `business_unit_items`
passes `EXPLAIN (ANALYZE, BUFFERS)` on the "larger retail" profile before
merge (D24).

### 8.3 Search strategy

*Decided:* prefix only, on name, code and SKU, case- and accent-insensitive,
under the `C` collation. Both sides go through one immutable SQL function,
`catalog.search_key(text)` (migration `0078`): it lowercases the text and
folds the Latin accents with `translate` (`áàâäãå`, `éèêë`, `íìîï`,
`óòôöõ`, `úùûü`, `ñ`, `ç`, `ýÿ` and their capitals; `ß` becomes `ss`),
so the stored keys and the searched text can never drift; `unaccent` is not
used because it is an extension and is not immutable, which a stored
generated column requires. Reason: `pg_trgm` is not installed (`0000_extensions.sql`
creates only `btree_gist`), adding an extension is an operations change, and
POS/back-office searches are typed from the start of a word. Known cost:
the order follows the folded lowercase key in `C` byte order, so it ignores
locale rules (`Ñandú` sorts as `nandu`, between `nabo` and `nata`); the
list is still stable and keyset-safe. Substring or fuzzy search
(`pg_trgm` GIN) is a later decision driven by measured complaints.

### 8.4 What `perf:http` must measure

| Route | Class | Why |
|---|---|---|
| `GET /catalog/outlets/{id}/sellable-items` (200 and 304) | hot | Every POS start and every refresh |
| `GET /catalog/outlets/{id}/barcode-matches` | hot | Every retail scan |
| `GET /catalog/outlets/{id}/sellable-item-search?q=` | hot | POS search on a large outlet |
| `GET /catalog/items?q=` | frequent | Back-office search |
| `GET /catalog/workspace` | frequent | Back-office landing |
| `POST /catalog/items`, `PATCH /catalog/items/{id}` | administrative | Not in `perf:http` (D24) |

Fixture: a "representative world" catalog (restaurant 300 items; retail
20,000 variants) added to `tooling/performance/http/representative-world.ts`;
1 client and 16 clients; standard budget from `AGENTS.md` §5. A local run
never proves production capacity (`AGENTS.md` §5 last paragraph).

### 8.5 Statement budget

| Route | Statements (target) |
|---|---|
| C17 `304` | tenant context (1, migration `0066`) + permission (1) + versions and BU (1) = **3** |
| C17 `200` | 3 + items/variants (1) + groups/options (1) + categories (1) = **6** |
| C18 | 3 + lookup (1) = **4** |
| C19 | 3 + page (1) = **4** |
| C4 create | context, authorization, version row lock, `ensure_default_catalog`, item, variants (one multi-row insert), links, audit, commit = **≈9** |
| C22 `PUT` (B1) | session (2) + `BEGIN` + context + permission + version row lock + composition lock + capability + components + retire groups + upsert groups + retire choices + upsert choices + re-read + audit + `COMMIT` = **16**, whatever the number of groups; **8** for an unchanged composition |
| C22 `GET` (B1) | session (2) + `BEGIN` + context + permission + composition + `COMMIT` = **7** |
| C3 `GET` item (API v7 PR 6) | session (2) + `BEGIN` + context + permission + the item in one statement + `COMMIT` = **7**, plus one `describe` when the item or a variant has an image; 11 before PR 6, when the read was five statements (`test/e2e/statement-budget.e2e-spec.ts` › `creates, reads, answers 304 for, edits and classifies an item in the measured statements`) |
| C3 `304` (API v7 PR 6) | the same **7**: the tag is a hash of the representation, which holds the delivery status and URLs of the images and the names and statuses of shared modifier groups and options, so it is computed from the one-statement read |
| C4 create, C5 `PATCH`, `PUT .../categories` (API v7 PR 6) | **14**, **10** and **13** (18, 14 and 21 before): the create and the `PATCH` read the item once, so each saves four statements; the categories `PUT` reads it under the lock and again for the answer, so it saves eight |

A use case that needs more than these numbers is a design smell to review,
not a hard gate.

***

## 9. Delivery slices

Each slice is one PR into `staging`, ≤ 400 changed lines where possible,
with its tests, docs and case-matrix rows (`AGENTS.md` §7). Sizes: S 1–2 h,
M 3–5 h, L 6 h+ of agent work.

| # | Slice | Cases | Depends on | Size | Risk | Security-critical |
|---|---|---|---|---|---|---|
| **K1** | Catalog foundations in SQL: migration with runtime grants on `catalog` (USAGE; SELECT/INSERT/UPDATE on the v1 tables; DELETE only on `item_categories` and `item_modifier_groups`, the latter revoked by K9c in revision 101; EXECUTE on `ensure_default_catalog`, `lock_hierarchy`, `assert_variant_listed`, `resolve_barcode`, `variant_is_listed`), `catalog_versions` (G2) with triggers, `listed_items` (G4), four permissions + role mapping (§7.2), capability `catalog.items` + pionero next version, audit resource kinds and action types. SQL contract tests (`database/tests/contracts/`) including "runtime cannot DELETE an item", "listed\_items ≡ variant\_is\_listed" | — | — | M | High | **Yes** (grants, RLS reach, permissions) |
| **K2** | Module skeleton + create/read/update/lifecycle of items with default variant | C3, C4, C5, C7 | K1 | M | Medium | Yes (first tenant writes; authorization wiring) |
| **K3** | List/search, workspace, categories | C1, C2, C8, C9, C10 | K2 | L (split K3a reads / K3b categories if > 400 lines) | Medium | No |
| **K4** | Variants, SKU, barcodes | C6, C11 | K2 | M | Medium | No |
| **K5** | Modifier groups and item offers | C12, C13 | K2 | M | Medium | No |
| **K6** | Business-unit assortment | C14, C15, C16 | K1, K3 | M | High (BU-anchored authorization, dual row semantics) | **Yes** (anchored permission) |
| **K7** | POS reads + perf | C17, C18, C19, `perf:http` rows, EXPLAIN evidence, index migration | K3–K6 | L | High (hot path) | No |
| **K8** | `CatalogSellableContract` | C20 | K6 | S | Medium | Yes (admission atomic with sales) |

Default order: K1 → K2 → K3 → K5 → K4 (OD1: restaurant first) → K6 →
K7 → K8. K4 and K5 are independent; the order follows OD1.

### K1 as built (migration `0075_catalog_foundations`, baseline revision 85)

| Piece | As built |
|---|---|
| Runtime grants | `USAGE` on `catalog`; `SELECT, INSERT, UPDATE` on `catalogs`, `items`, `item_variants`, `item_variant_barcodes`, `categories`, `item_categories`, `modifier_groups`, `modifier_options`, `item_modifier_groups`, `business_unit_catalogs`, `business_unit_items`; `DELETE` only on `item_categories` (and on `item_modifier_groups` until revision 100: K9c revokes it, an offer is retired and never deleted); `SELECT` and `UPDATE (version)` on `catalog_versions`. Every other catalog table has no grant; `ninaku_worker` gets nothing |
| Functions | `EXECUTE` on `ensure_default_catalog`, `lock_hierarchy`, `variant_is_listed`, `assert_variant_listed`, `resolve_barcode`, `listed_items` and also `assert_item_default_variant`: the deferred default-variant trigger calls it as the writing role, so an item insert fails at commit without it. `lock_bundle_graph`, `assert_bundle_composition_acyclic` and the two version trigger functions stay barred |
| RLS | Every granted table already had a tenant policy with the scalar subquery and `WITH CHECK` on writes; no gap was found. `catalog_versions` gets one `FOR ALL` tenant policy |
| `catalog_versions` (G2) | One row per organization, created by the trigger `organizations_catalog_version_insert_trg` when the organization is inserted (provisioning or any other path) and backfilled by the migration, never lazily by `ensure_default_catalog`: the runtime then needs no `INSERT` on the table and the row exists before the first catalog write. Statement-level triggers with transition tables on the eleven tables above bump it once per `INSERT`, `UPDATE` or `DELETE` statement. Each bump takes the row lock until commit, so catalog writes of one organization serialize on it, and every catalog command locks the row `FOR UPDATE` before any other row (lock order, §5). The `BEFORE UPDATE` trigger `catalog_versions_step_trg` (`catalog.guard_catalog_version_step`) lets the version move only forward by exactly one and never to another organization (`catalog_version_step`, `23514`), so the runtime's `UPDATE (version)` grant cannot roll a version back (stale `304`) or push it to the `bigint` maximum (every later write failing with `22003`) |
| `listed_items` (G4) | `catalog.listed_items(organization, business unit)` returns `(item_id, item_variant_id)`, `STABLE`, `SECURITY INVOKER`, pinned `search_path`, PL/pgSQL so it can refuse another organization with `42501` (an unknown or foreign business unit answers an empty set). Its predicate is the one of `variant_is_listed`; `database/tests/contracts/catalog-listing-differential-contracts.ts` proves both agree on eight units (no row, `all` with an exclusion and an active row, `selected` with listings of active, inactive and archived items, assortment off, inactive unit, `selected` with only an exclusion, a foreign unit, an unknown unit) and an extra catalog in `assigned` mode, with zero differences |
| Permissions | The four codes of §7.1, mapped exactly as §7.2; organization-defined roles are untouched |
| Capability | `catalog.items` on a new Pionero version 4 (copy of version 3 plus the capability); version 3 is retired, which retires its offerings, and no subscription moves (see `SUBSCRIPTION_PLANS_USE_CASES.md`, "Catalog capability rollout") |
| Audit | Resource kinds `catalog.item`, `catalog.category`, `catalog.modifier_group`, `catalog.assortment`; the 36 action types named in §5, category `configuration`, retention class `administration.platform` (R16), tenant scope, risk `medium`. Where §5 writes "..." for modifier groups, K1 seeds `reactivated`, `archived` and the option lifecycle (`option_deactivated`, `option_reactivated`, `option_archived`) that its routes name |
| Generic tags (OD9) | `tag_groups`, `tag_group_translations`, `tags`, `tag_translations`, `item_tags`, `menu_tags` dropped; nothing outside them referenced them. `dietary_tags` stays |
| Update lists | UPDATE is granted on whole tables; K2 repositories never put `item_id`, `organization_id`, `catalog_id` or `created_at` in an UPDATE `SET` list (§5, "Update lists"): `SET item_id = ...` on `item_variants`, even to the same value, fires the bundle-graph guard triggers and fails closed with `42501` |
| Not in K1 | G3 (`items.creation_idempotency_key`) belongs to C4, so K2 adds it; G5 adds no column (the application sets `updated_at`) |

### K2 as built (migration `0076_catalog_item_creation_is_idempotent`, baseline revision 86)

| Piece | As built |
|---|---|
| Module | `src/modules/catalog/` with `domain/` (code generation, lifecycle transitions, profile changes, sales quantities: pure TypeScript), `application/` (four use cases, `CatalogAccess`, ports), `infrastructure/` (item and catalog version repositories) and `presentation/` (controller, DTOs, failures); registered in `AppModule`. It imports only `identity/contracts`, `platform/contracts` and `audit/contracts`; the module boundary check needed no new rule. `contracts/` arrives with `CatalogSellableContract` (K8): an empty layer would be dead code |
| Routes | `POST /catalog/items` (`201`), `GET /catalog/items/{itemId}` (`200`, `304`), `PATCH /catalog/items/{itemId}` (`200`), `POST /catalog/items/{itemId}/{deactivation,reactivation,archival,restoration}` (`200`, one transitions route since API v7 PR 2, C7); every route behind `SessionGuard` and `TenantContextGuard`, errors as Problem Details |
| Authorization | Gated commands (create, edit, reactivation, restoration) call `OperationAuthorizationContract` with `catalog.items.manage` and `catalog.items` and no anchor, so only an organization-wide scope satisfies them and the order of `platform.authorize_operation` holds: tenant context, permission, scope, capability. Deactivation and archival call `PermissionCheckContract` organization-wide and never ask about the plan. The read calls `PermissionCheckContract` with `catalog.items.view` first, anchored at a business unit when `businessUnitId` is sent; only when that check refuses and the caller holds the permission organization-wide does it look the unit up, and an unknown unit then answers `404 · organization.structure_not_found`. Every other refused caller gets the same `403 · catalog.action_forbidden` for an existing unit, an unknown unit and no unit, so a member cannot probe which unit ids exist. Every refused command leaves one denied tenant audit record whose reason is the decision (`permission_missing`, `scope_unavailable`, `capability_not_entitled`) and whose target is the item id of the path (or the organization for a create), even when the caller lacks the permission and the id names nothing; the volume of those records is bounded only by the rate limiter. No catalog route sets its own limit: like the Organization write routes, they fall under the global `ThrottlerGuard` of `RateLimitModule` (`DEFAULT_RATE_LIMIT`, 120 requests per minute per client IP, behind one trusted proxy hop); only some Identity routes declare a stricter `@Throttle`. Reads record nothing |
| Refusal mapping | `catalog/presentation/failures`: `permission_missing` and `tenant_context_missing` answer `403 · catalog.action_forbidden`, `scope_unavailable` `403 · authorization.scope_unavailable`, `capability_not_entitled` `403 · platform.capability_not_entitled`; any other refusal answers exactly the plain denial, so a caller without the permission never learns about the plan |
| Codes | Item codes and SKUs are unique per organization and catalog, ignoring case (`catalog_items_code_ci_uq` and `item_variants_sku_ci_uq` on `(organization_id, catalog_id, lower(...))`); with one default catalog per organization today that is the whole organization. `catalog.action_forbidden`, `catalog.not_found`, `catalog.code_taken`, `catalog.sku_taken`, `catalog.item_archived`, `catalog.transition_invalid`, `catalog.unit_unknown`, `catalog.quantity_policy_invalid`, plus the reused `foundation.validation_failed`, `foundation.precondition_failed`, `idempotency.key_reused`, `idempotency.in_progress` and `organization.structure_not_found`. `catalog.item_refused` and `catalog.default_variant_required` are not built: no K2 path reaches them (the create always writes the default variant and K2 never changes a variant's status) |
| Lock order | Every command decides authorization first, then locks `catalog.catalog_versions` `FOR UPDATE`, then the item row `FOR UPDATE OF i` (edit and transitions). The create calls `ensure_default_catalog` after the version lock, so a generated code is chosen while the organization's catalog writes are serialized: parallel creates of one name get `base`, `base-2`, `base-3` with no conflict |
| Update lists | `UPDATE catalog.items` sets only `code`, `name`, `description`, `status` and `updated_at`; a unit test fails if a `SET` list names `item_id`, `organization_id`, `catalog_id` or `created_at` |
| Item code (OD2) | Optional; when omitted the server takes the name, strips accents, lowercases it, turns every run of other characters into one hyphen, bounds it to 60 characters (so a suffix fits the 80 of the column) and falls back to `item` when nothing is left; on collision it takes the lowest free `-N` from 2, ignoring case. Editable with `PATCH` |
| Idempotency (G3) | One new column, `catalog.items.creation_idempotency_key uuid`, with the partial unique index `catalog_items_creation_idempotency_uq (organization_id, creation_idempotency_key) WHERE creation_idempotency_key IS NOT NULL`: the claim lives in the module's own table, as `FOUNDATION_HTTP_AND_OBSERVABILITY.md` §7 prescribes, and `API_AND_SCHEMA_DISCIPLINE.md` admits it because a named use case (a retried create) reads and writes it and no other table holds the fact. The use case reuses `resolveIdempotentClaim`: the claim is `INSERT ... ON CONFLICT (organization_id, creation_idempotency_key) DO NOTHING`, the existing item is found by its key, and the intent matches when the name, the description, the explicit code (when sent) and the default variant's SKU, unit and quantities (compared in their shortest decimal form) equal the stored item. A replay answers `201` with the item and `Idempotent-Replayed: true`; another request with the same key `409 · idempotency.key_reused`. Because the claim is the item itself, the replay compared with the item as it is now, so a retry after an edit answered `409 · idempotency.key_reused`. *Closed in revision 91* ("Idempotent creates as built"): the hash of the original request is stored next to the key and a late retry replays the item as it is now |
| Item representation and ETag | C3 answers the item and its `variants[]` (all of them, default first); barcodes, categories and modifier groups join the representation, and so its hash, in the slices that write them (K3 to K5). The strong ETag is the SHA-256 of the item as answered, so two equal representations share one tag (an edit that is undone gives the old tag back). Write answers (`201`/`200`) carry the ETag of the item they return and `Cache-Control: private, no-store` through `HttpTaggedWriteResponse` (foundation), never answer `304`, and do not declare the header in OpenAPI, where a declared ETag means a conditional read; the read declares it with `ApiConditionalRead` |
| Lifecycle (OD8) | §4.4 as decided: a transition already in effect answers `200` and records nothing; reactivating an archived item and restoring an active one answer `422 · catalog.transition_invalid`; restoration returns to `inactive`; an archived item is readable but its profile answers `422 · catalog.item_archived`. Every write accepts an optional `If-Match` with the item's ETag |
| Performance | `GET /catalog/items/{itemId}` is classified frequent and not measured yet (the representative world has no catalog until K7); the commands are administrative |

### K3 as built (migration `0077_catalog_items_are_searchable_by_prefix`, baseline revision 87)

| Piece | As built |
|---|---|
| Routes | `GET /catalog/workspace` (`200`), `GET /catalog/items` (`200`), `GET /catalog/categories` (`200`, `304`), `PUT /catalog/items/{itemId}/categories` (`200`), `POST /catalog/categories` (`201`), `PATCH /catalog/categories/{categoryId}` (`200`), `POST /catalog/categories/{categoryId}/re-parenting` (`200`, folded into `PATCH /catalog/categories/{categoryId}` since API v7 PR 3), `POST /catalog/categories/{categoryId}/{deactivation,reactivation,archival}` (`200`, one transitions route with `restore` since API v7 PR 3) |
| Read authorization | The three reads share K2's rule (§5, "K2 as built"): `catalog.items.view` organization-wide or at the business unit named by `businessUnitId`, checked before any lookup; only an organization-wide reader learns that a unit is unknown. Never gated by the plan. The tree is not yet readable with `catalog.sellables.view` at an outlet: that anchor arrives with the point-of-sale reads (K7), which carry the tree anyway |
| Search evidence (§8.2, §8.3) | Under row level security PostgreSQL only uses a filter as an index condition when every function in it is leakproof; `lower()`, `LIKE` and record comparison are not, so on a seeded catalog of 20,000 items an expression index on `lower(name)` still scanned the organization: a page after a cursor read 7,158 buffers and filtered 9,861 rows. Migration `0077` adds three stored generated keys under the `C` collation, `items.name_search_key`, `items.code_search_key` and `item_variants.sku_search_key` (each `lower()` of its column until migration `0078` replaced it with `catalog.search_key()`), and the indexes `catalog_items_name_search_idx (organization_id, catalog_id, status, name_search_key, id)`, `catalog_items_code_search_idx` and the partial `item_variants_sku_search_idx`. Keyset comparison on `(name_search_key, id)` and `starts_with(key, lower(q))` are leakproof, so both become index conditions. The shipped statement, which also joins the page's default variant, variant count and primary category, measured locally on that catalog (16,000 active items, 60 categories): first page 2.1 ms, a page after a cursor 0.8 ms (725 buffers), a backward page 0.7 ms, a rare prefix 0.7 ms, a common prefix 1.9 ms. The widest prefix (`p-`, the code prefix of all 16,000 active items) gathers every match before ordering it: 25.7 ms and 7,768 buffers; a local single-machine run, not a capacity claim |
| C2 list and search | Order `(name_search_key, id)`; `q` is a prefix of the name, the code or any SKU after trimming, 1 to 80 characters, compared without case and without accents since migration `0078` (`catalog.search_key`, see "Leftovers closed"), and `%`, `_` and backslash are literal because the search never uses `LIKE`. `status` defaults to `active`; `categoryId` keeps the items linked to that category, primary or not, and an unknown or foreign category lists nothing. `listed` is not built: it reads the assortment of K6. The cursor binds the route, the organization, the status, `q`, `categoryId` and `businessUnitId`, and is decoded only after the view permission is granted: a refused member gets the plain `403` whatever cursor it sends, an authorized one the `collection.cursor_*` failures |
| C1 workspace | `catalog` is `null` until the first catalog write creates the default catalog; reading never creates it. `counts` per status, the tree (at most 500 nodes, the creation cap, so no truncation flag), the first page of active items with a cursor `GET /catalog/items` accepts with the same `businessUnitId` (the cursor binds it, so a reader assigned to one unit pages on at that unit), `assortment.mode` of the named unit (`all` without a row, `none` when its row is inactive), and `allowedActions`: `edit` when `catalog.items.manage` organization-wide and the plan allow it, `retire` when the permission does; computing them records no refusal. Classified frequent, not hot, so it has no statement budget row |
| C10 tree | One statement: the catalog version and the tree, depth first by `sortOrder`, lowercase name and id, each node with its depth, at most 500 (the creation limit, so always the whole tree; `meta.truncated` was removed in the API v7 foundation, `meta` is `{}`). The strong ETag was `organization:version:categories-<status or all>`; in v1 any catalog write of the organization changed it (the C02 correction), a write of another organization never does. Since API v7 PR 7 it is `organization:category-tree:<tree version>:<status or all>` and only category writes change it. A `status` filter keeps a child of an excluded parent with its `parentCategoryId` |
| C9 categories | The code is required (a retried create answered `409 · catalog.code_taken` instead of duplicating; since revision 91 the create also requires `Idempotency-Key` and a retry replays). Every command locks the version row, then the category `FOR UPDATE`, then reads the organization's hierarchy to judge a parent: unknown or foreign `404 · catalog.not_found`, under itself or a descendant `422 · catalog.category_cycle`, archived `422 · catalog.category_archived` (until 2026-10-05 any parent that was not active answered `422 · catalog.category_not_active`, a code that no longer exists), deeper than three levels counting the moved branch `422 · catalog.category_depth_exceeded`. Only acyclicity is also enforced in SQL (`catalog.prevent_category_cycle` through `categories_prevent_cycle_trg`, and `categories_not_self_parent_chk`); the depth limit of three and the parent that is not archived are enforced in the domain only, under the version row lock. `categories_prevent_cycle_trg`, `categories_not_self_parent_chk`, `categories_code_ci_uq` and the parent foreign keys are mapped as backstops. What a parent's lifecycle does to its subcategories is decided in "Category lifecycle and effective visibility" (§5.3, owner decision 2026-10-05): they keep their own status, are hidden by it through the derived `isEffectivelyActive` and `blockedByAncestorId`, and can be reactivated whatever the parent's status. Archiving is final (no restoration route; K1 seeded no `catalog.category.restored`): an archived category answers `422 · catalog.category_archived` to an edit or a move and `422 · catalog.transition_invalid` to a reactivation. Category writes carried no ETag and accepted no `If-Match` until revision 91, which added the per-category tag and `GET /catalog/categories/{categoryId}` ("Idempotent creates as built"); the tree keeps its organization-wide tag. `UPDATE` lists name only `code`, `name`, `description`, `sort_order`, `parent_category_id`, `status` and `updated_at` |
| C8 classification | The item is locked after the version row; then the requested categories are read `FOR SHARE` in id order. A category added to the item must not be archived (`422 · catalog.category_archived`; since 2026-10-05 an inactive one is accepted); one it already had may stay after it was archived. The links are replaced by one `DELETE` and one multi-row `INSERT`, so moving the primary never trips `item_categories_one_primary_uq`. One `catalog.item.categories_set` record keeps `catalog.item.categories` (sorted ids) and, when it changes, `catalog.item.primary_category`. The item representation of C3 gains `categories[]` (primary first, then name) and so does its hash: every item tag changed once with K3 |
| Migration 0077, operational note | It adds stored generated columns, which rewrite `catalog.items` and `catalog.item_variants` under an `ACCESS EXCLUSIVE` lock, and builds its three indexes without `CONCURRENTLY`. That is acceptable now because no production catalog exists yet; any later change of this kind on these tables needs an online approach (nullable column backfilled in batches, `CREATE INDEX CONCURRENTLY` outside the migration transaction) |
| Not built in K3 | `categoryIds` and `primaryCategoryId` in `POST /catalog/items` and a measured `perf:http` scenario for the three reads; both are closed below ("Leftovers closed") |

### Leftovers closed (migration `0078_catalog_search_ignores_accents`, baseline revision 88)

| Piece | As built |
|---|---|
| C4 categories in the create | `POST /catalog/items` accepts `categoryIds` (at most 20, each once) and `primaryCategoryId` (one of them; without `categoryIds`, or outside them, `400`). The links are written in the transaction of the item, after the catalog version row, the item and its default variant, with the categories read `FOR SHARE` in id order: an unknown or foreign category answers `404 · catalog.not_found`, an archived one `422 · catalog.category_archived` (since 2026-10-05; an inactive one is accepted), and nothing is created. The `catalog.item.created` record gains `catalog.item.categories` and `catalog.item.primary_category`; no `catalog.item.categories_set` record is written. The replay compares the classification too, so the same key with other categories answers `409 · idempotency.key_reused`. C8 and C4 share one check (`usable-categories.ts`) |
| Classification failures | The repository maps both foreign keys from a link to a category, `item_categories_category_organization_fk` (the one PostgreSQL checks first) and `item_categories_category_id_catalog_fk`, to `404 · catalog.not_found`, and `item_categories_one_primary_uq` to `422 · catalog.primary_category_conflict`; before, an unknown category reaching the `INSERT` answered `500`. A repeated category id never reaches the use case: both request schemas refuse it with `400` |
| Accent-insensitive search | `catalog.search_key(text)` is `IMMUTABLE`, `STRICT` and `PARALLEL SAFE`, has a SQL-standard body and is executable by `ninaku_runtime`; `0078` sets the three generated columns to it with `ALTER COLUMN … SET EXPRESSION`, which rewrites `catalog.items` and `catalog.item_variants` under an `ACCESS EXCLUSIVE` lock and rebuilds their indexes, keeps the column order, and is acceptable while no production catalog exists (same note as `0077`). The list applies the same function to `q`; the keyset order stays `(name_search_key, id)` and the plan keeps the prefix and the cursor as index conditions under row level security (`database/tests/contracts/catalog-search-key-contracts.ts`). Item codes are ASCII by their check, so the code key only lowercases in practice. No index was added on the code key: the code is only matched as a prefix inside the name-ordered list, never paged by itself |
| Hierarchy depth check | The height of a moved branch is computed level by level and stops after four levels or at a repeated node, so a corrupted cycle or a very deep chain answers `422 · catalog.category_depth_exceeded` instead of overflowing the stack |
| Workspace | The counts are copied field by field into the strict response. An organization that never wrote its catalog reads an empty tree under one stable strong tag (its `catalog_versions` row exists from provisioning) and gets `304` for it. A named unit without a `business_unit_catalogs` row answers `assortment.mode: all`, which is what `variant_is_listed` decides for the default catalog, whose `access_mode` is always `organization` (`catalogs_default_scope_chk`) |
| Performance | `perf:http` seeds one catalog of 2,000 items (1,800 active), 64 categories in three levels and one or two links per item, and measures `GET /catalog/items` (first page, page six after a cursor, `q=cafe`), `GET /catalog/workspace` and `GET /catalog/categories` (`200` and `304`), with 1 and 16 clients; results in `docs/operations/PERFORMANCE_BASELINE.md` |
| Category lifecycle and effective visibility (2026-10-05) | Built as decided in §5.3: the placement judge refuses only an archived parent (`catalog.category_archived`), reactivation no longer judges the parent, C4 and C8 refuse only an archived category, and every category representation (`GET /catalog/categories`, the categories of `GET /catalog/workspace`, every category write answer) carries `isEffectivelyActive` and `blockedByAncestorId`, derived by `domain/categories/category-visibility.ts`. The public code `catalog.category_not_active` was removed: nothing raises it any more. No SQL object, no migration, the baseline stays at revision 88 |
| Replay after an edit | Closed in revision 91 (owner decision): a retry with the same `Idempotency-Key` after the item was edited replays the item as it is now, because the hash of the original request is stored next to the key ("Idempotent creates as built") |

### K5 as built (no migration, baseline revision 88)

| Piece | As built |
|---|---|
| Schema | No migration. `modifier_groups`, `modifier_options` and `item_modifier_groups` (K1 grants, RLS, catalog namespace triggers, statement-level version bumps) already hold every fact C12 and C13 need, and K1 seeded every audit action type the routes use. Nothing about prices, price deltas, stock or recipes is stored or answered |
| Routes | `POST /catalog/modifier-groups` (`201`), `GET /catalog/modifier-groups` (`200`), `GET /catalog/modifier-groups/{modifierGroupId}` (`200`, `304`), `PATCH /catalog/modifier-groups/{modifierGroupId}` (`200`), `POST /catalog/modifier-groups/{modifierGroupId}/{deactivation,reactivation,archival}` (`200`, one transitions route with `restore` since API v7 PR 3), `POST /catalog/modifier-groups/{modifierGroupId}/options` (`201`), `PATCH /catalog/modifier-groups/{modifierGroupId}/options/{optionId}` (`200`), `POST /catalog/modifier-groups/{modifierGroupId}/options/{optionId}/{deactivation,reactivation,archival}` (`200`, one transitions route with `restore` since API v7 PR 3), `PUT /catalog/items/{itemId}/modifier-groups` (`200`); every route behind `SessionGuard` and `TenantContextGuard`, errors as Problem Details |
| Authorization | As K2 and K3: writes need `catalog.items.manage` organization-wide; create, edit, option add and edit and both reactivations are gated by `catalog.items`; deactivations and archivals never ask about the plan. The offer checks the plain permission first, and only after the item is locked asks about the plan, and only when the list adds a group, changes a rule or the order: a list that only removes groups (and an identical list) is ungated, so a lapsed plan can detach groups. A refused command leaves one denied record whose target is the group of the path (the organization for a create, the item for an offer). The two reads need `catalog.items.view` organization-wide or at `businessUnitId`, checked before any lookup, never gated, recording nothing |
| Codes | New: `422 · catalog.modifier_group_archived`, `422 · catalog.modifier_option_archived`, `422 · catalog.modifier_group_not_active`, `422 · catalog.variant_not_active` (named in C12), `422 · catalog.selection_rule_invalid` (named in C13), `422 · catalog.option_order_incomplete`, `422 · catalog.modifier_group_full` (review fix). Reused: `catalog.action_forbidden`, `catalog.not_found`, `catalog.code_taken`, `catalog.transition_invalid`, `catalog.item_archived`, `foundation.precondition_failed`, `foundation.validation_failed`, `organization.structure_not_found`. The detail of `foundation.precondition_failed` no longer says "the item", since a group answers it too. `modifier_groups_code_ci_uq`, `modifier_options_group_code_uq`, the variant and group foreign keys and `item_modifier_groups_selection_chk` are mapped as backstops, never shown |
| Group (C12) | The group code is required, unique per catalog ignoring case, so a retried create never duplicates; since revision 91 the create also requires `Idempotency-Key` and a retry replays the group as it is now instead of answering `409` ("Idempotent creates as built"). Up to 50 options per create, in the order sent (`sortOrder` 0, 1, ...); an option code is optional there and derived from its name like an item code, `-2`, `-3` within the group, `option` when nothing is left. `kind` is `instruction` (no target) or `catalog_variant` with `variantId`, which must name an existing variant of the organization (`404` otherwise) that is `active` at that moment (`422 · catalog.variant_not_active`); its later deactivation changes nothing here. `maxQuantity` 1 to 999; `isDefault` was free, several defaults allowed (the selection rules live on each item, so a group cannot know how many defaults fit); superseded by the hardening round, where the defaults belong to each item offer. `PATCH` edits the code, the name and the description and, with `optionOrder`, the whole order: every option of the group, whatever its status, exactly once. Lifecycle as categories: deactivation, reactivation, archival; archiving is final (K1 seeded no `restored` action), an archived group is readable and stays offered where it already was, and it and its options refuse every change with `catalog.modifier_group_archived` |
| Option (C12) | `POST .../options` appends an active option, and refuses it with `422 · catalog.modifier_group_full` when the group already holds 50 options of any status (archived options count: they are still loaded, hashed and named by `optionOrder`); its code is required (since revision 91 the add also requires `Idempotency-Key`, so a retry replays the group instead of answering `409`, "Idempotent creates as built"), unique within the group ignoring case, checked under the group lock and backstopped by `modifier_options_group_code_uq`. `PATCH` changes code, name, description and `maxQuantity` (`isDefault` until the hardening round); kind and variant never change (archive and add another, so a past sale keeps its meaning). Lifecycle deactivation, reactivation, archival; an archived option refuses edits with `catalog.modifier_option_archived` and reactivation with `catalog.transition_invalid`. Reactivation does not judge the variant again. No option lifecycle changes the group, another option or any item (rule h) |
| Group ETag | Strong, the SHA-256 of the group and its options as answered, like the item; `GET` answers `Cache-Control: private, no-cache` and `304` on `If-None-Match`; every group and option write accepts the group tag in `If-Match` (`412` when stale, nothing written) and answers the new tag with `Cache-Control: private, no-store`. The list is bounded (500 groups, the creation limit, so always whole; `meta.truncated` was removed in the API v7 foundation, `meta` is `{}`), not cursor-paged and not tagged |
| Offer (C13) | `PUT /catalog/items/{itemId}/modifier-groups` replaces the ordered list (at most 20, each group once) with `minSelections` 0 to 999 and `maxSelections` 1 to 999 per group; the list order is the `sort_order`. Domain rules: minimum not above the maximum nor above the units the available options of the group can reach, the sum of their `maxQuantity` judged with `activeOption`, the structural half of the till's rule, which has no unit (`422 · catalog.selection_rule_invalid`; F1, before it the count of active options); a group added to the item must be active (`422 · catalog.modifier_group_not_active`); a group the item already offers with the same rule stays even if it was deactivated, archived or lost its options since, and is judged again only when its rule changes. Unknown or foreign group or item `404 · catalog.not_found`; archived item `422 · catalog.item_archived`; an inactive item accepts it. One offer set per item: `item_variant_id` is always null (per-variant offers out of v1, as C13 decided). The links are replaced by one `DELETE` scoped to `item_variant_id IS NULL` (so a variant-scoped row, which no route writes today, is never removed unaudited) and one multi-row `INSERT`; one `catalog.item.modifier_groups_set` record keeps `catalog.item.modifier_groups` before and after as `groupId:min:max` in order; the same list again answers `200` and records nothing |
| Item representation | C3 gains `modifierGroups[]` in offer order: the group id, code, name and own status, the item's `minSelections` and `maxSelections`, and every option of the group with its status. The item ETag hashes it, so every item tag changed once with K5, and renaming an offered group or editing, adding, reordering or retiring one of its options changes the tag of every item that offers it (the rename, an option edit and an option deactivation are proven over HTTP) |
| Lock order | Every command takes the `catalog.catalog_versions` row `FOR UPDATE` first, then the group row `FOR UPDATE OF g` (group and option commands) or the item row (offer), then shares the referenced variants (`FOR SHARE OF v`) or the offered groups (`FOR SHARE OF g`) in id order. A refused command locks nothing, except an offer refused for the plan, which is decided after the item lock because only the stored list tells whether it merely removes groups. `UPDATE` lists never name `item_id`, `organization_id`, `catalog_id`, `created_at`, `modifier_group_id`, `option_kind` or `item_variant_id` (a unit test checks the statements) |
| Performance | `GET /catalog/modifier-groups` and `GET /catalog/modifier-groups/{modifierGroupId}` are classified frequent and not measured (the scenarios seed no modifier groups yet); the commands are administrative. The item read gains two statements (offers, their options) |
| Still open | Point-of-sale use of the groups (C17 hides inactive groups and options and an item whose required group is unusable, counting an option whose group is not active or whose `catalog_variant` target is inactive or archived as unavailable; see the K7 requirement in C17 and C19; the K5 writes never re-check it) is K7; `admit` validation of options is K8; a creation key for groups was built in revision 91 ("Idempotent creates as built"), while the group code stays required; the where-used read (which items offer a group) was built in revision 92 ("Contract step and where-used as built") |

### K4 as built (migration `0079_catalog_barcodes_are_unique_while_active`, baseline revision 89)

| Piece | As built |
|---|---|
| Schema | One migration. `item_variant_barcodes_value_uq` becomes partial (`WHERE status = 'active'`): the K1 index made a value unique among all barcodes, inactive ones included, which contradicted C11 ("to move it, deactivate and add on the other variant; reactivation fails if the value is now active elsewhere"); now a retired barcode frees its value. The audit action `catalog.item.barcode_updated` is seeded for the barcode edit. No column and no table: the size by colour attributes live only in the variant code and name, so the vertical stays neutral. Superseded by revision 119 (K13): the matrix now also writes structured dimensions, see "K13 as built"; the code and the name stay as described here. No new grant (K1 already grants `SELECT, INSERT, UPDATE` on both tables) |
| Routes | `POST /catalog/items/{itemId}/variants` (`201`), `PATCH /catalog/items/{itemId}/variants/{variantId}` (`200`), `POST .../variants/{variantId}/default-selection` (`200`, folded into `PATCH /catalog/items/{itemId}` since API v7 PR 2), `POST .../variants/{variantId}/{deactivation,reactivation,archival}` (`200`, one transitions route with `restore` since API v7 PR 2, C6), `POST /catalog/items/{itemId}/variant-matrix` (`201`, or `200` when already in effect), `POST .../variants/{variantId}/barcodes` (`201`), `PATCH .../barcodes/{barcodeId}` (`200`), `POST .../barcodes/{barcodeId}/{deactivation,reactivation}` (`200`), `GET /catalog/outlets/{outletId}/barcode-matches` (`200`, C18) and `GET /catalog/barcode-matches` (`200`, back office); every route behind `SessionGuard` and `TenantContextGuard`, errors as Problem Details |
| Answers and tags | Every write answers the whole item (C3 shape) with its strong ETag and `Cache-Control: private, no-store`, and accepts the item tag in `If-Match` (`412` when stale, nothing written). `variants[]` gains `barcodes[]` (`barcodeId`, `barcode`, `symbology`, `isPrimary`, `status`; active first, primary first), so every item tag changed once with K4, and a variant or barcode change changes the item tag. Both lookups answer `Cache-Control: private, no-cache` and no ETag: a scan is a per-request answer (C18 "not ETagged") |
| Authorization | Writes need `catalog.items.manage` organization-wide; add, edit, default selection, matrix and both reactivations are gated by `catalog.items`; deactivations and archival never ask about the plan. A refused command leaves one denied record targeting the item of the path and locks nothing. The till lookup checks `catalog.sellables.view` at the outlet first; only a caller holding it organization-wide learns that an outlet does not exist (`404 · organization.structure_not_found`), everyone else gets the plain `403`. The back-office lookup uses the read rule of K2 (`catalog.items.view` organization-wide or at `businessUnitId`). Lookups never ask about the plan and record nothing |
| Variants (C6) | The code is required and unique within the item ignoring case (`409 · catalog.code_taken`, also the backstop of `item_variants_item_code_uq`), so a retried add never duplicates and needs no `Idempotency-Key`. The create of an item still carries no named variants: they are added afterwards, one by one or as a matrix. At most 500 variants per item, archived ones included (`422 · catalog.item_variants_full`). The default variant can never be deactivated or archived (`422 · catalog.default_variant_required`), whatever the item status, so reactivating an item never fails at commit; only an active variant can become the default (`422 · catalog.variant_not_active`). The default selection releases the former default first and then sets the new one, two statements in one transaction, because `item_variants_one_default_per_item_uq` is not deferrable; since a variant that is not the default must have a name, a nameless former default needs `formerDefaultName` (`422 · catalog.variant_name_required`). Archiving a variant is final (no restoration route, as §3.2 says): reactivation answers `422 · catalog.transition_invalid` and an edit `422 · catalog.variant_archived`. Archiving a variant, and archiving an item (also on a lapsed plan, since archival is never gated), deactivates its active barcodes in the same transaction, recorded once as `catalog.item.barcode_deactivated` with the barcode and its status before and after for each, so their values are free for other variants; restoring the item or reactivating anything never reactivates them, and archiving again writes nothing. No lifecycle of a variant changes the item or another variant. An archived item refuses every variant and barcode command with `422 · catalog.item_archived` |
| Matrix (C6, K4c) | Extended by K13 ("K13 as built"): the same command now persists the dimensions, their values and the links of each variant, links a variant that exists with identical values instead of refusing it, fixes the dimensions of the item with its first matrix and refuses a repeated combination; everything below still holds except where "K13 as built" says otherwise. Up to three dimensions of 1 to 50 values (codes of letters, digits, dots or underscores, unique ignoring case) crossed in the order sent, at most 100 variants per command (`400` beyond). Code `m-azul`, name `M / Azul`, optional SKU `<skuPrefix>-<code>` in upper case, one quantity policy for all. All or nothing: a combination that exists with other values `409 · catalog.code_taken`, a taken SKU `409 · catalog.sku_taken`, 500 variants passed `422 · catalog.item_variants_full`. A matrix whose combinations all exist, none archived, with exactly these values is already in effect, like a repeated transition: `200` with the item and its tag, no `Idempotent-Replayed` header (that header belongs to a stored `Idempotency-Key`), nothing written or recorded. An archived variant holding the code of a combination is a clash (`409 · catalog.code_taken`), never the same matrix. One `catalog.item.variant_added` record lists every new variant. Barcodes per cell are not part of the command |
| Barcodes (C11) | Value trimmed, at most 160, never NUL, and stored as a GTIN: an all-digit value of 12 digits (UPC-A) is stored and answered in its 13-digit EAN-13 form with a leading 0, one of 14 digits with a leading 0 without it, so one GTIN is one value whatever its encoding and the active-value index sees it once; the declared symbology stays as sent, and other values (free-form symbologies included) are stored exactly as sent after trimming, with no Unicode or inner-space normalisation; `ean13`, `ean8`, `upca` and `upce` need their digits and a correct GS1 check digit (UPC-E expanded to UPC-A), other symbologies or none take the value as sent (`422 · catalog.barcode_invalid`). The value is checked under the version lock against the active barcodes of the catalog (`409 · catalog.barcode_taken`) and backstopped by the partial unique index. `isPrimary` takes the primary away from the former one in the same command; deactivation clears the primary; only an active barcode can become primary (`422 · catalog.barcode_not_active`). Value and variant never change: the edit (`PATCH`) changes only the symbology and the primary. At most 20 barcodes per variant, inactive included (`422 · catalog.variant_barcodes_full`). Reactivation refuses a value active elsewhere (`409`) |
| Lookup (C18) | One statement after the permission resolves the business unit operating the outlet today (`organization.effective_business_unit` on the site's date) and the active barcode, lock-free, keeping it only when `catalog.variant_is_listed` admits the variant for that unit; K6 is not built, so every unit sells the default catalog (`all`, the SQL default), and the answer follows any assortment row K6 writes later with no change here. No match, an outlet no unit operates today or another organization's code answer `200` with `data: null`; two sellable matches `409 · catalog.barcode_ambiguous` (only reachable once several catalogs exist). The back-office lookup answers the item and variant of an active barcode whatever their status, ordered by barcode id and bounded to two rows like the till, with the same `409 · catalog.barcode_ambiguous` for two matches (a dead branch with one catalog per organization). Both lookups compare a scanned all-digit value as a GTIN, as on the add. Statement budget of the scan: 7 (session, membership, `BEGIN`, tenant context, permission, lookup, `COMMIT`); it is classified hot and measured by the `catalog-barcode-scan` scenario (2,000 barcodes, one till outlet) |
| Lock order | Version row `FOR UPDATE`, then the item `FOR UPDATE OF i`; the variants and barcodes of the item are written only under that item lock, so they are not locked again. `UPDATE` lists never name `item_id`, `organization_id`, `catalog_id`, `created_at`, `item_variant_id` or the barcode value (a unit test checks the statements) |
| Index evidence | `EXPLAIN (ANALYZE, BUFFERS)` as `ninaku_runtime` under row level security on 2,500 items, 5,000 variants and 10,000 barcodes (half inactive), local: the back-office lookup and the add's value check use `item_variant_barcodes_value_uq` with `organization_id` and `barcode` as index conditions (`catalog_id` skipped, 2 to 3 index searches), 1.2 ms and 0.03 ms warm; the till lookup the same index plus `variant_is_listed`, 1.0 ms; an exact SKU uses `item_variants_sku_search_idx`, 0.03 ms. No index was added. A local single-machine run, not a capacity claim |
| Still open | Barcodes in the matrix command; a read of one variant (the item read carries them); per-business-unit listing (K6) and the POS reads C17, C19 (K7); `admit` (K8) |

### Hardening round as built (migration `0080_catalog_defaults_belong_to_the_item_offer`, baseline revision 90)

| Piece | As built |
|---|---|
| Why | K5 kept the default on the reusable option (`modifier_options.is_default`) while the selection rule lives on each item offer, so a group with two default options offered with `maxSelections` 1 made the item start in a state its own rule forbids, and two items could not preselect different options of one group. Owner decision ("the best for the business"): the default belongs to the item offer |
| Schema | One migration. `catalog.item_modifier_group_defaults (item_modifier_group_id, modifier_option_id, modifier_group_id, catalog_id, organization_id, created_at)`, primary key on the pair, a composite foreign key to the offer `(id, modifier_group_id, catalog_id, organization_id)` `ON DELETE CASCADE` and one to the option with the same four columns, through the new unique keys `item_modifier_groups_id_group_catalog_org_uq` and `modifier_options_id_group_catalog_org_uq`: SQL itself guarantees that an offer preselects only options of its own group, catalog and organization, which a `uuid[]` column on the offer could not (an array element has no foreign key). Indexes on the option and on the group serve the removals, and the partial index `modifier_options_variant_idx (organization_id, item_variant_id) WHERE item_variant_id IS NOT NULL` serves the cleanup of a retired variant (`EXPLAIN` on 3,000 options under row level security: an index scan with both columns as index conditions, proven by `database/tests/contracts/catalog-offer-default-migration-contracts.ts`). Tenant RLS with the scalar subquery, statement-level catalog version bumps on insert, update and delete, and `ninaku_runtime` gets `SELECT, INSERT, DELETE` only (no `UPDATE`, so no row can move to another organization). The count bound (`≤ maxSelections`) and the availability of a default stay domain rules under the version row lock, like the other K5 rules. The migration carries each existing option default to the offers of the items that already offer its group, only while the option, its group and its target variant are active and only up to the offer's `max_selections` in option order (`ON CONFLICT DO NOTHING`, so it can run again); production holds no catalog data yet, so this carry-over is for local and staging databases only. **Expand step only:** because the migrator runs before the new API (`RAILWAY_OPERATIONS.md`), the migration keeps `modifier_options.is_default` (`NOT NULL DEFAULT false`), which the previous API still reads and writes during the rollout; the new API never reads or writes it (a unit test checks every modifier statement). **The contract step** was built in revision 92: migration `0082` drops the unused column ("Contract step and where-used as built") |
| Routes and representation | `isDefault` is gone from the option create, add and edit requests (sending it answers `400`) and from every option answer. `PUT /catalog/items/{itemId}/modifier-groups` takes `defaultOptionIds` per group (UUIDs, each once, at most 50 because a group holds at most 50 options, absent means empty), and C3 answers it on every offered group in the order of the group's options; the item ETag hashes it, so a change of defaults, including one caused by retiring an option, changes the item tag. The `catalog.item.modifier_groups` audit value appends `:<sorted default ids joined by +>` to a group that has defaults (`groupId:min:max` otherwise, as before) |
| Rules | `422 · catalog.default_options_invalid` (*new*) when a default is repeated, more defaults than `maxSelections` are sent, or a default is not an active option of an active group whose `catalog_variant` target is active; an unknown option, one of another group and one of another organization answer exactly the same, so nothing leaks. The selection rule is judged first. An offer kept identical, defaults included, is not judged again. A change of defaults only is gated by the plan, like any rule change; a pure removal of groups keeps the defaults of the kept groups and stays ungated |
| Lifecycle | Deactivating or archiving an option, deactivating or archiving its group, and deactivating or archiving a variant sold by a `catalog_variant` option each remove the option(s) from every offer's defaults with one `DELETE` in the same transaction; the command's record gains `catalog.modifier_group.offer_defaults_removed` (option and group commands) or `catalog.item.variant.offer_defaults_removed` (variant command) with the number of offers that lost a default, only when some did. Reactivation never adds a default back. Every one of these commands takes the catalog version row first, like the offer, so an option deactivation and an offer preselecting it serialise and no default ever points at an unavailable option |
| Business limits | An organization holds at most 500 categories and 500 modifier groups of any status, archived ones included because the tree and the list still load them: the 501st create answers `422 · catalog.category_limit_reached` or `422 · catalog.modifier_group_limit_reached`, counted after the version row lock, so no race passes it and no migration was needed. Reactivation and restoration create nothing and are unaffected. One constant per collection in `application/catalog-limits.ts` is both the limit and the read cap, so `meta.truncated` could never become true and the API v7 foundation removed it from both reads |
| Performance | `perf:http` measures `GET /catalog/modifier-groups` and `GET /catalog/modifier-groups/{modifierGroupId}` (`200` and `304`) on 150 groups of 7 options; all rows meet the standard budget with 1 and 16 clients (`docs/operations/PERFORMANCE_BASELINE.md` §14.3), local evidence only |
| Recorded debt (not built) | The Assets milestone (the `assets` schema exists, no code reads or writes it, and `catalog.item_assets` is only the link). The idempotency keys of the category, group and option creates and `If-Match` for category writes were built in revision 91 ("Idempotent creates as built") |

### K6 as built (no migration, baseline revision 90)

| Piece | As built |
|---|---|
| Schema | No migration. `business_unit_catalogs` (mode `all` or `selected`, status `active` or `inactive`), `business_unit_items`, `guard_assortment_change`, `variant_is_listed`, `listed_items`, their RLS with the scalar subquery and the K1 grants (`SELECT, INSERT, UPDATE`, no `DELETE`) already hold every fact C14 to C16 need, and K1 seeded the three audit actions. A unit with no `business_unit_catalogs` row sells everything (`variant_is_listed`: no row and a catalog in `organization` mode, which the default catalog always is), so a new business unit needs no row (OD5); the baseline stays at revision 90 |
| Routes | `GET /catalog/business-units/{businessUnitId}/assortment` (`200`, `304`), `PUT /catalog/business-units/{businessUnitId}/assortment` (`200`), `POST .../assortment/listings` (`200`), `POST .../assortment/delistings` (`200`); and C16 as decided, `GET /catalog/items?businessUnitId=&listed=true\|false`. Every route behind `SessionGuard` and `TenantContextGuard`, errors as Problem Details |
| Representation and tags | `{ businessUnitId, mode, listedCount }`; `listedCount` is `count(DISTINCT item_id)` of `catalog.listed_items(org, unit)`, the items the unit sells now, so `selected` with nothing listed answers `0` and the screen can warn. The strong tag is `<organization>:<catalog version>:assortment-<unit>:<mode>:<unit status>`: the organization-wide catalog version, like the category tree, so every assortment write bumps it, no lost update passes `If-Match`, and any catalog write of the organization changes it (accepted, as C10); plus the mode and the status of the unit, because deactivating or reactivating a unit changes what it sells (`listed_items` needs an active unit) without moving the catalog version (K6 review, S1: the old tag answered `304` with a stale `listedCount`). `If-Match` is compared after the version lock and the read of the stored mode and unit status. The read answers `Cache-Control: private, no-cache` and `304` on `If-None-Match` (weak comparison, so `W/"…"` matches); the writes answer the tag with `private, no-store` and take `If-Match` with the strong comparison (`412 · foundation.precondition_failed`, nothing written, checked right after the version lock). A command already in effect answers `200` with the same tag, writes and records nothing, and never sets `Idempotent-Replayed` |
| Authorization (anchored, OD3) | The permission is `catalog.assortment.manage` at the unit of the path: `OperationAuthorizationContract` with the `business_unit` anchor (`platform.authorize_operation_at`) for the mode and the listings, `PermissionCheckContract` with the anchor for the delistings; an organization-wide scope also satisfies it. The decision comes before any lock or lookup. A caller it refuses with a plain denial gets the same `403 · catalog.action_forbidden` for its own unit, another unit, an unknown unit and a unit of another organization, with one denied record targeting the unit id of the path; only a caller holding the permission organization-wide learns `404 · organization.structure_not_found` (no record, nothing locked). `catalog.items.manage` alone does not curate, and `catalog.assortment.manage` alone does not edit items. The read accepts `catalog.items.view` or `catalog.assortment.manage` at the unit, with the same rule for unknown units, and records nothing. The unit is taken only from the path: every body is strict, so `organizationId` or `businessUnitId` in a body answers `400` |
| Plan and place | The mode change and the listings are gated by `catalog.items` (`403 · platform.capability_not_entitled`, audited, after the permission); delistings and the read never ask about the plan. An inactive unit answers the gated commands `422 · authorization.place_not_operational` (now mapped by the catalog failures; before K6 no catalog route used an anchor, so it never arose); an organization-wide holder may still read and delist there (repair), and a member assigned to the inactive unit gets `403 · authorization.scope_unavailable` |
| Mode (C14) | `all` and `selected` are an active row of that mode, `none` an inactive row that keeps the mode it had (`all` when there was no row). The listings and delistings already made are kept and keep their meaning when the mode changes again, so switching `none` back to `selected` restores what the unit listed. One `catalog.assortment.mode_changed` record keeps `catalog.assortment.mode` before and after. The first write of a unit calls `ensure_default_catalog` after the version lock, like the item create |
| Listings (C15) | `{ itemIds }`, 1 to 200, each once (`400` otherwise), all or nothing. The API speaks of listed and not listed; the command writes only what changes: in `selected` a listing inserts a missing inclusion or reactivates an inactive row and a delisting deactivates an inclusion; in `all` a delisting inserts a missing exclusion or deactivates an active row and a listing reactivates an exclusion (an item without a row is already listed). When no unit row exists yet the command first writes the `all` row the listings need as their parent. An unknown item or one of another organization answers `404 · catalog.not_found` for the whole call; listing an archived item answers `422 · catalog.item_archived` (*Decided*: the design named a new `catalog.item_not_active`, but only archived items are refused, an inactive item may be listed and sells once reactivated, and the existing code says exactly that); a unit in mode `none` answers `422 · catalog.assortment_disabled` (*new*; owner decision of the K6 review, "follow the standard": a state refusal is `422`, and the catalog answers `409` only for uniqueness clashes) to both commands. Delisting never changes an item, its status or another unit. One `catalog.assortment.items_listed` or `.items_delisted` record per call keeps `catalog.assortment.items`, the sorted ids that changed; a call that changes nothing records nothing |
| Lock order | Authorization at the unit, then `catalog.catalog_versions` `FOR UPDATE` (the If-Match check), then the stored mode, then the requested items `FOR SHARE OF i` in id order; the row writes fire `guard_assortment_change`, which locks the unit row `FOR UPDATE` (proven by a `NOWAIT` probe, and a mutation that drops the guard fails it). Two managers of different units therefore serialise on the version row and never deadlock; a listing and an item archival serialise the same way (`422 · catalog.item_archived` when the archival came first). `UPDATE` lists name only `assortment_mode` and `status` (a unit test checks them), never `id`, `organization_id`, `business_unit_id`, `catalog_id`, `item_id` or `created_at`, which the guard refuses anyway |
| C16 `listed` filter | `listed=true` keeps the items the unit's assortment admits, `false` the others, whatever their variants; the status filter still applies and `listed` needs `businessUnitId` (`400`). The cursor binds `listed` only when it is sent, so cursors issued before K6 stay valid. The predicate resolves the unit's mode once (`none` for an inactive unit or catalog, `all` without a row) and tests the item against a hashed set of the unit's inactive rows (`all`) or active rows (`selected`), the rule of `variant_is_listed` without the item and variant status. `EXPLAIN (ANALYZE, BUFFERS)` as `ninaku_runtime` under row level security on 20,000 items, local: a first page with 10,000 listed 4.2 ms, with 10 listed 4.1 ms and 4,998 buffers (a correlated `EXISTS` read 184,702 buffers and took 44 ms there, so it was replaced), `listed=false` 1.0 ms; the item order stays on `catalog_items_name_search_idx` |
| Other evidence | The command's item read uses `catalog_items_name_search_idx` with `id = ANY(...)` as an index condition (200 ids, 2.4 ms); the status update uses the unique `(business_unit_id, item_id)` index (101 rows, 7.0 ms, 3.3 ms of it in the guard trigger, R9); `listedCount` costs 21.5 ms with 10,000 of 20,000 listed and 6.7 ms with 10 (one `listed_items` call); the OD5 check uses `business_units_organization_status_idx` and `business_unit_verticals_organization_status_idx` (0.14 ms). Local single-machine runs, not a capacity claim |
| Workspace | `allowedActions` gains `curate` when `businessUnitId` is sent and the caller may set the mode there on the current plan (the same anchored, gated decision, recording nothing). `warnings` (*new*, OD5) answers `units_with_different_verticals` when the active business units of the organization do not all run the same set of active verticals, so the screen can say that a new unit sells the whole shared catalog until its assortment is chosen. The warning describes the whole organization, so it is answered only to a caller holding `catalog.assortment.manage` organization-wide, the person who curates every unit and so must react to a new one; a reader assigned to one unit, or one who only reads the catalog, gets an empty list (K6 review, S2) |
| Till (C18) | Unchanged code: the lookup already keeps a match only when `variant_is_listed` admits it, which reads the K6 rows, so a unit in `selected` mode that has not listed the variant answers `data: null` at its outlets while another unit that sells it resolves it, `none` sells nothing, `all` everything, and a delisting at one unit never changes another unit's till (a mutation that drops the listing rule fails both tests). Statement budget unchanged: 7 |
| Drift guard | The `listed` filter writes the listing rule a third time (after `variant_is_listed` and `listed_items`), on purpose, for its plan; `src/modules/catalog/infrastructure/reads/listed-filter.integration-spec.ts` proves it agrees with both on every mode (no row, `all`, `selected`, turned off from either), active and inactive unit, and a listing, an exclusion and no row, so they cannot drift (a mutation that ignores the unit status fails it) |
| Performance | The four routes are classified administrative and not measured: they are opened only while curating a unit |
| Still open | K7 reads the assortment through `catalog.listed_items`, which already follows the K6 rows; the K7 requirements recorded in C17 and C19 stand. Per-variant assortment is not built (the listings are per item, as decided). `admit` (K8) does not call `catalog.assert_variant_listed`; the function stays until Sales is built, because the order-line trigger of the Sales schema (`database/canonical/0110_sales.sql`, created by the canonical install although no module uses Sales yet) calls it as its database-level admission. A creation key or a request hash for these commands is not needed: every command is a set operation that answers `200` when repeated |

### D1 as built (migration `0099`, baseline revision 109)

The owner's D1 decision (audit of `18d8e128`; design in
`CATALOG_OPTIONS_V2.md` §18) replaces OD5 (a) for new units.

| Piece | As built |
|---|---|
| Rule | When the registration of a business unit commits, the deferred constraint trigger `business_units_default_assortment_trg` (`AFTER INSERT ON organization.business_units`, `DEFERRABLE INITIALLY DEFERRED`, owned by Catalog like `organizations_catalog_version_insert_trg`) runs `catalog.materialize_default_assortment()`: a unit that already has a row on the default catalog (written in the same transaction) is left alone; otherwise it stores one row on the default catalog (creating that catalog when missing), active `all` when no other unit of the organization has an active vertical, or when each active vertical of the unit is active in another unit of the organization, and inactive `all` (read `none`) otherwise, which includes a unit registered without verticals |
| Stored once | Nothing recomputes it: activating or deactivating a vertical later never touches the row, and reads still answer from the stored row; only C14 changes it |
| Concurrency | The trigger takes the transaction advisory lock keyed `organization.verticals:<organization id>`, the lock every vertical writer takes (Platform's `activate` and the last-vertical guard), before it reads, so a registration committing while another unit of the organization turns a vertical on decides after that activation and counts it (`database/tests/concurrency/catalog-default-assortment-concurrency.ts`; without the lock the registration decides `none` from a stale read). Two registrations in one organization were already serialised by the structure version row their inserts update, so the second one always sees the first (`test/e2e/business-unit-register-verticals.e2e-spec.ts`) |
| Security | `SECURITY DEFINER`, `search_path = pg_catalog, pg_temp`, `EXECUTE` revoked from `PUBLIC`: it runs at the commit of provisioning, which has no tenant context, as well as at the commit of a tenant registration. It writes only the row of the unit being inserted, in that unit's organization |
| Version and audit | The insert bumps the catalog version once (the existing statement trigger), so the assortment and till tags move. No catalog audit record: the default is part of the audited `organization.business_unit.registered` (and its `vertical_activated` records); later choices are audited by C14 |
| Choosing at creation | `POST /business-units` takes the optional `verticalCodes` (O9), activated in the registering transaction, so the trigger sees them. A client that wants another mode sends `PUT /catalog/business-units/{id}/assortment` right after; between the two requests the stored default applies, which never sells another line of business |
| Backfill | Migration `0099` creates the default catalog of every organization that has units and none, and gives every existing unit without a row an explicit active `all` row, so every existing unit keeps selling exactly what it sold (`database/tests/contracts/catalog-default-assortment-migration-contracts.ts`; upgrade from revision 108 with identical till lists) |
| Proof | `database/tests/contracts/catalog-default-assortment-contracts.ts`, `test/e2e/business-unit-default-assortment.e2e-spec.ts`, `test/e2e/business-unit-register-verticals.e2e-spec.ts` |

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

C22, the composition of a combo (design in `CATALOG_OPTIONS_V2.md` §19). A
combo is a normal item that owns bundle groups: a restaurant combo, a retail
kit or a service package use the same routes and nothing in them is
restaurant-only. Catalog stores no price for it; Pricing prices the item
itself plus one surcharge per choice (`pricing.bundle_group_item_price_entries`).
Admission came next in B2 ("B2 as built"); Pricing and Sales (B3) follow:
the till reads (C17, C19) and `resolve` are unchanged.

| Piece | As built |
|---|---|
| Routes | `GET /catalog/items/{itemId}/bundle-groups` (view, any item, archived included, `businessUnitId` as in C3; strong ETag, `304`) and `PUT /catalog/items/{itemId}/bundle-groups` (manage; replaces the ordered groups and their ordered choices in one call). No `Idempotency-Key`: like the other catalog `PUT`s it is a replacement, and sending it again answers `200` with no write, no record and no version change |
| Vocabulary | `bundleGroups[]` with `bundleGroupId`, `code`, `name`, `minSelections`, `maxSelections`, `suspended`, `isSatisfiable` and `choices[]`; a choice has `choiceId`, `variantId`, `itemId`, `itemName`, `variantName`, `sku`, `itemStatus`, `variantStatus`, `quantityPerSelection` (decimal string, units of the variant one pick gives), `maxSelectionCount` (picks of this choice one sale may take, `null` for no cap), `isDefault`, `suspended` and `isAvailable`. The stored `status` stays internal: `suspended` is `inactive`, and `retired` rows are never answered, exactly as the modifier offer answers `suspended`. `minSelections` and `maxSelections` mean what they mean on modifier offers (C13) |
| Identity | A group keeps its id when the client sends `bundleGroupId` back; a choice is the pair (group, variant) and keeps its id whether or not `choiceId` is sent. Whatever the list leaves out is retired (`status = 'retired'`, widened CHECK), never deleted; sending it again revives the same id. `suspended: true` on a group or a choice writes it `inactive`; `false` or absent writes it `active`, so a suspended row is resumed by sending it without the flag and a retired one comes back as sent. A new group without `code` takes one derived from its name like an item code (lowercase ASCII with hyphens, `-2`, `-3` against every code the combo holds or held, `group` when nothing is left) |
| Rules before SQL (`400`, `meta.errors` with the field) | `minSelections` 0 to 999 and not above `maxSelections` (1 to 999); 1 to 50 choices per group, each variant once; `isDefault` on none or between the minimum and the maximum; `quantityPerSelection` a decimal above 0 and at most 999 (6 decimals, `"1"` by default); `maxSelectionCount` an integer from 1 to the group's `maxSelections`, or `null` (as `GET` answers it) or absent for no cap, so the body `GET` answers can be sent back as it is, and the caps of a group able to reach its `minSelections`; a suspended choice never `isDefault`; at most 20 groups; no repeated group, code or choice, each repetition pointed at its own element (`bundleGroups[1].bundleGroupId`); strict bodies (a `price` is an unknown field) |
| Rules that read the database | an unknown group, choice or variant `404 · catalog.not_found`; a code another group of the combo holds or held `409 · catalog.code_taken`; a `choiceId` with another variant `422 · catalog.bundle_choice_mismatch`; the combo itself or a combo that contains it `422 · catalog.bundle_cycle`; a new choice whose item or variant is archived, or of another catalog, `422 · catalog.bundle_component_unavailable` (a choice already in the group keeps its place); each with `meta.errors[]` (`location`, `field` such as `bundleGroups[2].choices[0].variantId`, `code`, a message that says how to fix it), only the first kind by precedence (not found, code, mismatch, cycle, unavailable). The ids the client sends back are judged before the composition is compared with the stored one, so an otherwise unchanged request with an unknown or mismatched id is refused, never answered `200`. An archived combo `422 · catalog.item_archived` |
| Cycles | The use case reads, in the statement that share-locks the requested variants, which of them belong to the combo or to a combo that contains it; the SQL guard stays the backstop and now raises `CONSTRAINT = 'bundle_composition_acyclic'`, mapped to the same `catalog.bundle_cycle`. Both look at the same live graph: retired groups and choices do not count, so a retired choice never forbids a later nesting; the graph triggers of both tables also fire on a status change, so reviving a retired row that would close a loop is refused with the same code |
| Availability | `isAvailable` is true when neither the choice nor its group is suspended and its item and variant are `active`. `isSatisfiable` is the one rule B2 judges too (`domain/bundles/bundle-satisfiability.ts`): an optional group always is; a required one is when it is not suspended and its available choices, each counted up to its `maxSelectionCount` (999 when absent), reach `minSelections`. Sold out (86) is Operations' and is not shown |
| Locks and concurrency | The lock order of every catalog command: the version row `FOR UPDATE`, then the combo item `FOR UPDATE` (with its composition, one statement), then the requested variants `FOR SHARE` in id order. Admissions hold the version row `FOR SHARE`, so a composition edit waits for open admissions of the organization and fails after the 5 s statement timeout under continuous overlap (K8 rule) |
| Version and audit | Triggers bump the catalog version once per statement kind on insert, update and delete of both tables (X15 for bundles); an upsert that changes nothing bumps nothing. One `catalog.item.bundle_groups_set` record per real change keeps `catalog.item.bundle_groups` before and after: each live group as `groupId:code:min:max:status:name[choiceId:variantId:quantityPerSelection:maxSelectionCount:isDefault:status, ...]`, the same key the change check compares, so a rename alone is recorded with a different before and after |
| Plan | An empty list (the item becomes plain again) never needs the plan; any other change is gated by `catalog.items` |
| ETag | A hash of the answer, choice names and statuses included: renaming or deactivating a component changes it, so `If-Match` fails with `412` and the client reads again. The C3 item tag and body are unchanged: the item read does not carry the composition, which would add a statement to every item read and write |
| Privileges | `ninaku_runtime` gets `SELECT`, `INSERT`, `UPDATE` (never `DELETE`) on `bundle_groups` and `bundle_group_items` and `EXECUTE` on `catalog.lock_bundle_graph(uuid)` and `catalog.assert_bundle_composition_acyclic(uuid)`, which the graph triggers call as the invoker |
| Limits | 20 groups per combo, 50 choices per group (as modifier offers and options), selections up to 999 (Sales' `selection_count`), names 180 characters and codes as the SQL checks |
| Proof | `test/e2e/catalog-bundle-groups.e2e-spec.ts`, `database/tests/contracts/catalog-bundle-groups-contracts.ts` (`NINAKU_CATALOG_BUNDLE_GROUPS_OK`), C22 rows of [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md); upgrade from revision 109 with seeded bundle rows gives the same schema and privileges as a fresh install |

**Example.** `PUT /api/v1/catalog/items/01930000-0000-7000-8000-0000000000b0/bundle-groups`
keeps the groups Principal and Bebida (ids sent back), adds Té helado to
Bebida and creates Acompañamiento:

```json
{
  "bundleGroups": [
    {
      "bundleGroupId": "01930000-0000-7000-8000-0000000000b1",
      "name": "Principal",
      "minSelections": 1,
      "maxSelections": 1,
      "choices": [
        {
          "choiceId": "01930000-0000-7000-8000-0000000000c1",
          "variantId": "01930000-0000-7000-8000-0000000000a2",
          "isDefault": true
        }
      ]
    },
    {
      "bundleGroupId": "01930000-0000-7000-8000-0000000000b2",
      "name": "Bebida",
      "minSelections": 1,
      "maxSelections": 1,
      "choices": [
        {
          "choiceId": "01930000-0000-7000-8000-0000000000c2",
          "variantId": "01930000-0000-7000-8000-0000000000a4",
          "isDefault": true
        },
        {
          "variantId": "01930000-0000-7000-8000-0000000000a6"
        }
      ]
    },
    {
      "code": "acompanamiento",
      "name": "Acompañamiento",
      "minSelections": 0,
      "maxSelections": 2,
      "choices": [
        {
          "variantId": "01930000-0000-7000-8000-0000000000a8",
          "quantityPerSelection": "1"
        }
      ]
    }
  ]
}
```

`200`, with `ETag` the strong tag of the composition (here Té helado is
inactive, so it is not available, while the group stays satisfiable through
Gaseosa):

```json
{
  "data": {
    "itemId": "01930000-0000-7000-8000-0000000000b0",
    "bundleGroups": [
      {
        "bundleGroupId": "01930000-0000-7000-8000-0000000000b1",
        "code": "principal",
        "name": "Principal",
        "minSelections": 1,
        "maxSelections": 1,
        "suspended": false,
        "isSatisfiable": true,
        "choices": [
          {
            "choiceId": "01930000-0000-7000-8000-0000000000c1",
            "variantId": "01930000-0000-7000-8000-0000000000a2",
            "itemId": "01930000-0000-7000-8000-0000000000a1",
            "itemName": "Hamburguesa clásica",
            "variantName": null,
            "sku": "HB-001",
            "itemStatus": "active",
            "variantStatus": "active",
            "quantityPerSelection": "1",
            "maxSelectionCount": null,
            "isDefault": true,
            "suspended": false,
            "isAvailable": true
          }
        ]
      },
      {
        "bundleGroupId": "01930000-0000-7000-8000-0000000000b2",
        "code": "bebida",
        "name": "Bebida",
        "minSelections": 1,
        "maxSelections": 1,
        "suspended": false,
        "isSatisfiable": true,
        "choices": [
          {
            "choiceId": "01930000-0000-7000-8000-0000000000c2",
            "variantId": "01930000-0000-7000-8000-0000000000a4",
            "itemId": "01930000-0000-7000-8000-0000000000a3",
            "itemName": "Gaseosa",
            "variantName": "500 ml",
            "sku": "GAS-500",
            "itemStatus": "active",
            "variantStatus": "active",
            "quantityPerSelection": "1",
            "maxSelectionCount": null,
            "isDefault": true,
            "suspended": false,
            "isAvailable": true
          },
          {
            "choiceId": "01930000-0000-7000-8000-0000000000c3",
            "variantId": "01930000-0000-7000-8000-0000000000a6",
            "itemId": "01930000-0000-7000-8000-0000000000a5",
            "itemName": "Té helado",
            "variantName": null,
            "sku": null,
            "itemStatus": "inactive",
            "variantStatus": "active",
            "quantityPerSelection": "1",
            "maxSelectionCount": null,
            "isDefault": false,
            "suspended": false,
            "isAvailable": false
          }
        ]
      },
      {
        "bundleGroupId": "01930000-0000-7000-8000-0000000000b3",
        "code": "acompanamiento",
        "name": "Acompañamiento",
        "minSelections": 0,
        "maxSelections": 2,
        "suspended": false,
        "isSatisfiable": true,
        "choices": [
          {
            "choiceId": "01930000-0000-7000-8000-0000000000c4",
            "variantId": "01930000-0000-7000-8000-0000000000a8",
            "itemId": "01930000-0000-7000-8000-0000000000a7",
            "itemName": "Papas fritas",
            "variantName": null,
            "sku": null,
            "itemStatus": "active",
            "variantStatus": "active",
            "quantityPerSelection": "1",
            "maxSelectionCount": null,
            "isDefault": false,
            "suspended": false,
            "isAvailable": true
          }
        ]
      }
    ]
  }
}
```

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

C28, admitting a combo (design in `CATALOG_OPTIONS_V2.md` §20). `admit` of
`CatalogSellableContract` takes, on a line, the components the customer
chose and answers the tree Sales freezes in
`sales.order_line_bundle_selections`; Catalog still answers no price. The
contract change is additive: a line without `bundleSelections`, or of an
item with no active group, is admitted and serialized exactly as before.

| Piece | As built |
|---|---|
| Input | `lines[i].bundleSelections[]`: `{ bundleGroupId, choices[] }`, each choice `{ choiceId, quantity, selections?, bundleSelections? }`. `quantity` is how many times the customer took that choice (integer 1 to 999, Sales' `selection_count`); `selections` are the modifiers of that component, `bundleSelections` the groups of a combo inside the combo. The same choice may come twice in a group (two occurrences with different modifiers) |
| Answer | `lines[i].bundleGroups[]` (only when the item has an active group): every active group in catalog order, `{ bundleGroupId, groupNameSnapshot, minSelections, maxSelections, choices, removedDefaults }`; each choice `{ choiceId, quantity, quantityPerSelection, appliedByDefault, snapshot, offers, bundleGroups? }`, in the order sent (a group left out holds its applied defaults in catalog order) |
| Defaults | A group left out gets its available defaults, one each, `appliedByDefault: true`; a group sent gets exactly its choices (an empty list means none), and the available defaults it left out come back in `removedDefaults` (`{ choiceId, itemNameSnapshot, variantNameSnapshot, defaultQuantity }`) |
| Judgement | the group is a live, active group of that combo; the choice a live, active choice of that group; minimum and maximum on the sum of quantities; a choice's `max_selection_count` on the sum of its entries; a required group whose available choices cannot reach its minimum is unsatisfiable, by the one rule B1 answers as `isSatisfiable` (`domain/bundles/bundle-satisfiability.ts`); a required group suspended with `PUT .../bundle-groups` refuses `bundle_group_unavailable` at the list even when the client leaves it out, as a required suspended modifier offer leaves its item unsellable, while an optional suspended group is simply not offered; each component by the sellable rule of a line (same statement, same fragment) and its modifiers by `judgeSelection` against its own effective offers; a nested combo recursively |
| Refusals | whole or nothing, `CatalogNotSellableError`. Inside a combo each refusal is a `BundleAdmissionRefusal` (`isBundleAdmissionRefusal`): `line`, `variantId` (of the line), `reason`, `modifierGroupId`, `optionId`, `path`, `bundleGroupId`, `choiceId`, `detail`. Reasons: `bundle_group_not_offered`, `bundle_group_unavailable`, `bundle_group_unsatisfiable`, `bundle_choice_not_offered`, `bundle_choice_unavailable`, `bundle_choice_above_maximum`, `bundle_selection_below_minimum`, `bundle_selection_above_maximum`, `bundle_too_deep`, `bundle_components_above_limit`, and for a component the line reasons (`not_listed`, `item_inactive`, `selection_below_minimum`, ...). A group or a default the client did not send points at the list where it belongs (`lines[0].bundleSelections`) |
| Request problems | before any lock: a choice quantity off 1 to 999 `invalid_quantity`; a group twice at one level or an option twice in a component `duplicate`; more than 100 components on a line or 20 groups at a level `too_many_components`; nesting deeper than 3 `too_deep` |
| Statements and locks | the groups of each variant come in the same `SELLABLE_FACTS` statement; the components of every line are read with that statement once per level: 6 statements for a plain line, 7 for a combo, 8 for a combo in a combo, whatever the number of groups. Each line's components are counted while each level is built: a line that passes 100 stops expanding there and is refused `bundle_components_above_limit`, so a level reads at most 100 components per line. One catalog version share lock, so a composition edit waits for open admissions |
| Not built | `resolve` still answers no composition: the till reads it from `GET .../bundle-groups`; prices are B3. Since B2b a combo that cannot be completed is left out of the till lists, the search, the scan and `resolve` ("B2b as built" in `CATALOG_OPTIONS_V2.md` §20.3), since F1b also a combo that can only be completed through a component combo that cannot, and since F1c one that can only be completed through components the unit cannot sell on their own (below) |

**F1b as built (no migration): a combo inside a combo.** Example: the Pack
familiar has a required group that offers a children combo, and the children
combo has a required group that offers a toy. Before F1b, suspending the toy
group left the children combo out of the till, but the Pack was still listed,
found, scanned and answered sellable by `resolve`, and only `admit` refused
it. Now a choice counts toward the minimum of its group only when it is
active, its variant and item are active and, when its component is itself a
combo, that combo can be composed too, one level further down, up to three
combo levels (`BUNDLE_MAX_DEPTH`). Below the third level a component combo
with a group the sale would open (an active group, or a suspended required
one) is not available, because `admit` refuses it with `bundle_too_deep`; B1
guards only cycles at write time, not depth, so such a composition can be
stored. The alternatives stay: if the same required group of the Pack also
offers a plain drink, the Pack stays sold while the children combo is out.
The pure rule is `isComposable` (`domain/sellable/bundle-composability.ts`),
over the groups of each component by variant; the SQL fragment
`bundleComposable` unrolls the same three levels as keyed scalar probes, and
`bundle-composable.integration-spec.ts` runs both on the same nested rows.
`resolve` answers the Pack `bundle_group_unsatisfiable`; `admit` is unchanged
and still explains the refusal with the group of the children combo.
Statement counts, `meta.omittedItems` and the bytes of an item without combo
groups do not change.

**F1c as built (no migration): a component the unit cannot sell.** Example:
a breakfast has a required group that offers a coffee, and the coffee has a
required milk group. Before F1c, suspending the milk group left the coffee
out of the till (its own required modifier could not be satisfied), but the
breakfast was still listed, found, scanned and answered sellable by
`resolve`, and only `admit` refused it, with `required_modifier_unsatisfiable`
on the coffee. The same happened when the coffee was delisted at the unit
(`not_listed`). Now a choice counts toward the minimum of its group only when
its component would be sold on its own at the reader's business unit by the
same rule a plain line uses: an active variant of an active item of the
default catalog, listed at the unit (`listedAt`, with the unit operable), its
required modifier offers satisfiable (`variantConfigurable`, reused, so an
optional group or a variant that opts out of a suspended group does not
block it) and, when it is itself a combo, composable down to the third combo
level as in F1b. Alternatives stay: with a tea in the same required group the
breakfast stays sold while the coffee is out; a listing at another unit does
not count. The till lists, the search, the scan and `resolve` already carry
the unit, so `bundleComposable` takes the scope of the reader instead of an
item alias; every probe stays a keyed `anyRowOf`, three levels unrolled in
the text. The domain twin `isComposable` now reads each component's
sellability facts by variant (the `SELLABLE_FACTS` row `admit` reads) and
judges it with `blockingRefusalOf`, the predicate `admit` applies, so the
combos the till leaves out are those `admit` refuses whatever the selection;
`bundle-component-sellable.integration-spec.ts` runs SQL and domain on the
same rows. `resolve` answers the breakfast `bundle_group_unsatisfiable`;
`admit` is unchanged and refuses the coffee at its path
(`lines[0].bundleSelections[0].choices[0]`). `meta.omittedItems` counts the
coffee while its milk is suspended (a plain item the unit cannot configure),
never the breakfast. Statement counts and the bytes of an item without combo
groups do not change; the plan bounds are in `PERFORMANCE_BASELINE.md` §14.6.

**Sales mapping (`0110_sales.sql:981-1146`).**

| Sales column | From |
|---|---|
| `bundle_group_id` | `bundleGroups[].bundleGroupId` |
| `bundle_group_item_id` | `choices[].choiceId` |
| `parent_bundle_selection_id` | the row of the enclosing choice, for `choices[].bundleGroups` |
| `selection_number` | the position of the choice in its group, from 1 |
| `group_name_snapshot` | `groupNameSnapshot` |
| `selected_item_name_snapshot`, `selected_variant_name_snapshot` | `choices[].snapshot.itemName`, `.variantName` |
| `selection_count` | `choices[].quantity` |
| `quantity_per_selection_snapshot` | `choices[].quantityPerSelection` |
| `quantity_unit_code_snapshot` | `choices[].snapshot.salesUnitCode` |
| modifier rows of the component (`order_line_bundle_selection_id`) | `choices[].offers[].selections`, as a line's |
| adjustments | Pricing (B3) |

**Example.** A combo whose Principal group the customer fills with a burger
cooked rare, two portions of fries, and no word about the drink:

```json
{
  "businessUnitId": "01930000-0000-7000-8000-000000000b01",
  "lines": [
    {
      "variantId": "01930000-0000-7000-8000-000000000c01",
      "quantity": "1",
      "selections": [],
      "bundleSelections": [
        {
          "bundleGroupId": "01930000-0000-7000-8000-000000000f01",
          "choices": [
            {
              "choiceId": "01930000-0000-7000-8000-000000000a01",
              "quantity": 1,
              "selections": [
                { "optionId": "01930000-0000-7000-8000-000000000d01", "quantity": 1 }
              ]
            }
          ]
        },
        {
          "bundleGroupId": "01930000-0000-7000-8000-000000000f03",
          "choices": [
            { "choiceId": "01930000-0000-7000-8000-000000000a05", "quantity": 2 }
          ]
        }
      ]
    }
  ]
}
```

The admitted line (snapshots and offers shortened to what Sales stores):

```json
{
  "lineIndex": 0,
  "snapshot": { "variantId": "01930000-0000-7000-8000-000000000c01", "itemName": "Combo hamburguesa", "...": "..." },
  "quantity": "1",
  "offers": [],
  "bundleGroups": [
    {
      "bundleGroupId": "01930000-0000-7000-8000-000000000f01",
      "groupNameSnapshot": "Principal",
      "minSelections": 1,
      "maxSelections": 1,
      "choices": [
        {
          "choiceId": "01930000-0000-7000-8000-000000000a01",
          "quantity": 1,
          "quantityPerSelection": "1",
          "appliedByDefault": false,
          "snapshot": { "itemName": "Hamburguesa", "variantName": null, "salesUnitCode": "unit", "...": "..." },
          "offers": [
            {
              "itemModifierGroupId": "01930000-0000-7000-8000-000000000e01",
              "groupNameSnapshot": "Término",
              "selections": [
                { "modifierOptionId": "01930000-0000-7000-8000-000000000d01", "optionNameSnapshot": "Jugoso", "quantity": 1, "...": "..." }
              ],
              "...": "..."
            }
          ]
        }
      ],
      "removedDefaults": []
    },
    {
      "bundleGroupId": "01930000-0000-7000-8000-000000000f02",
      "groupNameSnapshot": "Bebida",
      "minSelections": 1,
      "maxSelections": 1,
      "choices": [
        {
          "choiceId": "01930000-0000-7000-8000-000000000a03",
          "quantity": 1,
          "quantityPerSelection": "1",
          "appliedByDefault": true,
          "snapshot": { "itemName": "Gaseosa", "...": "..." },
          "offers": []
        }
      ],
      "removedDefaults": []
    },
    {
      "bundleGroupId": "01930000-0000-7000-8000-000000000f03",
      "groupNameSnapshot": "Acompañamiento",
      "minSelections": 0,
      "maxSelections": 2,
      "choices": [
        {
          "choiceId": "01930000-0000-7000-8000-000000000a05",
          "quantity": 2,
          "quantityPerSelection": "0.5",
          "appliedByDefault": false,
          "snapshot": { "itemName": "Papas fritas", "salesUnitCode": "kg", "...": "..." },
          "offers": []
        }
      ],
      "removedDefaults": []
    }
  ]
}
```

Sent without the Principal group, the same line is refused with one
refusal: `{ "line": 0, "reason": "bundle_selection_below_minimum", "path":
"lines[0].bundleSelections", "bundleGroupId":
"01930000-0000-7000-8000-000000000f01", "choiceId": null, "detail": "The
group needs more choices; add choices until the sum of their quantities
reaches its minimum.", ... }`.

Proof: `test/e2e/catalog-sellable-bundles.e2e-spec.ts` (with the Sales
parity helper `test/support/sales-bundle-parity.ts`: the real Sales
triggers store the rows built from the answer alone), the C28 rows of
[`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md).

### B2c as built: the till receives the composition of a combo (no migration, baseline revision 114)

C17 and C19 answer `bundleGroups` on every combo that opens at least one
active group, as described in C17
("Combos (B2c)"); the scan (C18) stays as it was. No route, permission or
statement is added: the composition is one column of the read statement
both share (`infrastructure/till/till-bundle-groups.queries.ts`), made of
correlated `json_agg` subqueries reached by the item key
(`bundle_groups_item_idx`, `bundle_group_items_group_idx`, one `LIMIT 1`
primary-key lookup for the item of each choice). A choice is kept by
`choiceAvailable`, exported from the sellable rule
(`infrastructure/reads/sellable-rule.queries.ts`): it is the very text the
B2b rule (`bundleComposable`) counts each choice with, so the till shows a
choice exactly when the rule that keeps the combo sellable counts it, nested
combos (F1b) and component sellability at the unit (F1c) included.

Proof:

* a plain item answers the same bytes, checked against a literal before the
  SQL changed (`test/e2e/catalog-till-combo-composition.e2e-spec.ts`);
* a cashier, refused the back-office read, sees the groups and choices of a
  combo in the read and the search, and the tag moves when the composition
  changes (same file);
* parity with `admit` on the same rows: every choice the till shows is
  admitted with that choice in its line and every hidden one refused with the
  expected reason, for suspended groups and choices, an
  inactive item and variant, an unlisted component, a component whose
  required modifier group is suspended, an incomplete component combo, a
  variant-specific choice, a component that needs a modifier, and combos
  nested three levels deep
  (`test/e2e/catalog-till-combo-composition-parity.e2e-spec.ts`);
* the shared text (`sellable-rule.queries.spec.ts`,
  `till-bundle-groups.queries.spec.ts`);
* plans under stale statistics, with a page of 50 combos holding combos
  three levels deep in the same page: no sequential scan, no looped scan
  wider than 100 rows, no lookup wider than the imported items, and per
  requested item at most 115 rows and 600 buffers for the 2,000-item list,
  200 rows and 1,050 buffers for a search page, 350 rows and 1,100 buffers
  for the 50-combo page (`till-read.plan.integration-spec.ts`). Those are
  about twice the worst of five runs on a freshly recreated database, with
  stale and with fresh statistics: 114,361 rows and 594,848 buffers for the
  list, 9,634 and 52,078 for the worst search page, 8,811 and 27,594 for the
  50-combo page. On the first measurement the composition about doubled the
  buffers of the 50-combo page and took its items from 34 KB to 102 KB
  (about 1.35 KB per combo of five choices). Each shown choice runs the full
  sellable check of its component, the same work `bundleComposable` already
  does for the required groups of every candidate.

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

C21, the structure of a menu (design in `CATALOG_OPTIONS_V2.md` §21). A menu
says what is shown: ordered sections, one level of subsections, and the items
each section shows. A restaurant card, a retail catalogue page or a list of
service packages use the same routes; nothing is restaurant-only. Which
outlet shows a menu (M2), time windows (S1), translations, the till read by
menu and admission by menu are later slices: C17 to C20 are unchanged.

| Piece | As built |
|---|---|
| Routes | `GET /catalog/menus` (every menu, archived ones included, by name, with `sectionCount` and `entryCount`; at most 100, never paged), `POST /catalog/menus` (an empty `draft`; `Idempotency-Key` as every catalog create), `GET /catalog/menus/{menuId}` (the whole tree; strong ETag, `304`), `PATCH /catalog/menus/{menuId}` ("Edit a menu", operation `MenusController_update_v1`: name or code), `PUT /catalog/menus/{menuId}/structure` (replaces the ordered tree in one call), `POST .../activation` and `POST .../archival` (one `POST .../transitions` with `activate`, `unpublish`, `archive` and `restore` since API v7 PR 3). Reads need `catalog.items.view` (organization-wide or with `businessUnitId`, as C3); writes need `catalog.items.manage` |
| Vocabulary | `sections[]` with `sectionId`, `code`, `name`, `suspended`, `entries[]` and `subsections[]` (same fields, no `subsections`); an entry has `entryId`, `itemId`, `itemName`, `itemStatus`, `variantMode` (`all` or `selected`), `suspended`, `isAvailable` and `variants[]` (`variantId`, `variantName`, `sku`, `status`); the menu adds `status` (`draft`, `active`, `archived`), `entryCount` and `availableEntryCount`. Order is the position in the array |
| Identity | A section keeps its id when the client sends `sectionId`, or the `code` a section of the menu holds (retired ones included), also when it moves between the top and a subsection; two sections of one request that resolve to the same stored section (one by its id, another by that section's code) are refused as a repetition (`400 · foundation.validation_failed` at the alias, such as `sections[1].code`); an entry is the pair (section, item) and keeps its id whether or not `entryId` is sent. What the tree leaves out is retired (`status = 'retired'`, widened CHECKs), never deleted, and sending it again revives the same id. `suspended: true` on a section or an entry writes it `inactive` (`false` or absent, `active`), as on combos and modifier offers. A new section without `code` takes one derived from its name (`-2`, `-3` against every code the menu holds or held, `section` when nothing is left), and a new menu without `code` one derived from its name against the codes of the catalog (`menu` when nothing is left) |
| Rules before SQL (`400`, `meta.errors` with the field) | `selected` needs 1 to 50 `variantIds`, `all` takes none; each item once per section and each variant once per entry; no section, code or entry twice in the tree; at most 50 sections and subsections, 200 entries per section and 1,000 in all; two levels only (a `subsections` inside a subsection is an unknown field); strict bodies (a price is an unknown field). The use case refuses the same repetitions again before any lookup |
| Rules that read the database | an unknown section, entry, item or variant `404 · catalog.not_found`; a code another section holds or held, sent with a `sectionId`, `409 · catalog.code_taken`; an `entryId` with another item or section `422 · catalog.menu_entry_mismatch`; a selected variant of another item `422 · catalog.menu_variant_mismatch`; a new item archived or of another catalog, or a newly selected archived variant, `422 · catalog.menu_item_unavailable` (an entry already in that section keeps its place); each with `meta.errors[]` naming the field, such as `sections[0].subsections[1].entries[2].itemId`, only the first kind by precedence. The ids sent back are judged before the tree is compared with the stored one. An archived menu `422 · catalog.menu_archived`; an active menu left without entries `422 · catalog.menu_empty` |
| Lifecycle | `activation` (draft → active, gated): `422 · catalog.menu_empty` without entries, `422 · catalog.menu_nothing_available` when none is available, `422 · catalog.transition_invalid` once archived (as every lifecycle route; `catalog.menu_archived` stays the answer of `PATCH` and `PUT .../structure`); `archival` (draft or active → archived, never gated). Since API v7 PR 3 these are `activate` and `archive` on `POST .../transitions`, joined by `unpublish` (active → draft, never gated) and `restore` (archived → draft, gated), so archiving is no longer final. Repeating a transition answers `200` and records nothing |
| Availability | `isAvailable` is true when neither the entry nor its section (nor the parent of a subsection) is suspended, its item is `active` and at least one offered variant is `active`. With `all` the entry offers every variant of the item that is not archived, the default first; with `selected` the chosen ones in order, whatever their status. Prices are Pricing's, sold out is Operations' |
| Locks and concurrency | The lock order of every catalog command: the version row `FOR UPDATE`, then the menu `FOR UPDATE OF` with its tree in one statement; the items and variants are read without row locks, since every catalog command that could change them waits on the same version row |
| Version and audit | Triggers bump the catalog version once per statement kind on insert, update and delete of `menus`, `menu_sections`, `menu_entries` and `menu_entry_variants` (X15 for menus); an upsert that changes nothing bumps nothing. Records: `catalog.menu.created` (code and name), `catalog.menu.updated` (what changed), `catalog.menu.structure_set` (`catalog.menu.structure` before and after: `sectionId:code:parent:position:status:name` per section (the same key the change check compares, so a rename alone is recorded with a different before and after), then `entryId:sectionId:itemId:mode:position:status[variants]` per entry), `catalog.menu.activated` and `catalog.menu.archived` (`catalog.menu.status`), target kind `catalog.menu` |
| Plan | Create, rename, a structure with sections and activation are gated by `catalog.items`; emptying a draft menu and archiving never are |
| ETag | A hash of the menu answer, item and variant names and statuses included: renaming or deactivating an item changes it, so `If-Match` fails with `412` and the client reads again |
| Privileges | `ninaku_runtime` gets `SELECT`, `INSERT`, `UPDATE` (never `DELETE`) on `menus`, `menu_sections`, `menu_entries`; `SELECT`, `INSERT`, `UPDATE`, `DELETE` on `menu_entry_variants` (a selection nothing references, rewritten when it changes); `EXECUTE` on `catalog.assert_menu_entry_variant_configuration(uuid)`, which the deferred entry triggers call as the invoker |
| Statements | `PUT .../structure` 18 whatever the number of sections and entries, 8 for an unchanged tree; `GET` 7; activation 10 |
| Proof | `test/e2e/catalog-menus.e2e-spec.ts`, `database/tests/contracts/catalog-menus-contracts.ts` (`NINAKU_CATALOG_MENUS_OK`), C21 rows of [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md); upgrade from revision 110 with seeded menu rows gives the same schema and privileges as a fresh install |

**Example.** `PUT /api/v1/catalog/menus/01930000-0000-7000-8000-0000000000d0/structure`
keeps Entradas and Principales (ids sent back), adds the subsection Frías
under Entradas, offers two sizes of the lomo and adds Postres:

```json
{
  "sections": [
    {
      "sectionId": "01930000-0000-7000-8000-0000000000d1",
      "name": "Entradas",
      "entries": [
        {
          "entryId": "01930000-0000-7000-8000-0000000000e2",
          "itemId": "01930000-0000-7000-8000-0000000000f3"
        }
      ],
      "subsections": [
        {
          "code": "frias",
          "name": "Frías",
          "entries": [
            {
              "itemId": "01930000-0000-7000-8000-0000000000f1"
            }
          ]
        }
      ]
    },
    {
      "sectionId": "01930000-0000-7000-8000-0000000000d3",
      "name": "Principales",
      "entries": [
        {
          "itemId": "01930000-0000-7000-8000-0000000000f5",
          "variantMode": "selected",
          "variantIds": [
            "01930000-0000-7000-8000-0000000000f6",
            "01930000-0000-7000-8000-0000000000f7"
          ]
        }
      ]
    },
    {
      "name": "Postres",
      "entries": [
        {
          "itemId": "01930000-0000-7000-8000-0000000000f8"
        }
      ]
    }
  ]
}
```

`200`, with `ETag` the strong tag of the menu:

```json
{
  "data": {
    "menuId": "01930000-0000-7000-8000-0000000000d0",
    "code": "almuerzo",
    "name": "Menú del almuerzo",
    "status": "draft",
    "entryCount": 4,
    "availableEntryCount": 4,
    "sections": [
      {
        "sectionId": "01930000-0000-7000-8000-0000000000d1",
        "code": "entradas",
        "name": "Entradas",
        "suspended": false,
        "entries": [
          {
            "entryId": "01930000-0000-7000-8000-0000000000e2",
            "itemId": "01930000-0000-7000-8000-0000000000f3",
            "itemName": "Sopa del día",
            "itemStatus": "active",
            "variantMode": "all",
            "suspended": false,
            "isAvailable": true,
            "variants": [
              {
                "variantId": "01930000-0000-7000-8000-0000000000f4",
                "variantName": null,
                "sku": "SOP-01",
                "status": "active"
              }
            ]
          }
        ],
        "subsections": [
          {
            "sectionId": "01930000-0000-7000-8000-0000000000d2",
            "code": "frias",
            "name": "Frías",
            "suspended": false,
            "entries": [
              {
                "entryId": "01930000-0000-7000-8000-0000000000e1",
                "itemId": "01930000-0000-7000-8000-0000000000f1",
                "itemName": "Ceviche de pescado",
                "itemStatus": "active",
                "variantMode": "all",
                "suspended": false,
                "isAvailable": true,
                "variants": [
                  {
                    "variantId": "01930000-0000-7000-8000-0000000000f2",
                    "variantName": null,
                    "sku": "CEV-01",
                    "status": "active"
                  }
                ]
              }
            ]
          }
        ]
      },
      {
        "sectionId": "01930000-0000-7000-8000-0000000000d3",
        "code": "principales",
        "name": "Principales",
        "suspended": false,
        "entries": [
          {
            "entryId": "01930000-0000-7000-8000-0000000000e3",
            "itemId": "01930000-0000-7000-8000-0000000000f5",
            "itemName": "Lomo saltado",
            "itemStatus": "active",
            "variantMode": "selected",
            "suspended": false,
            "isAvailable": true,
            "variants": [
              {
                "variantId": "01930000-0000-7000-8000-0000000000f6",
                "variantName": "Regular",
                "sku": "LOM-R",
                "status": "active"
              },
              {
                "variantId": "01930000-0000-7000-8000-0000000000f7",
                "variantName": "Grande",
                "sku": "LOM-G",
                "status": "active"
              }
            ]
          }
        ],
        "subsections": []
      },
      {
        "sectionId": "01930000-0000-7000-8000-0000000000d4",
        "code": "postres",
        "name": "Postres",
        "suspended": false,
        "entries": [
          {
            "entryId": "01930000-0000-7000-8000-0000000000e4",
            "itemId": "01930000-0000-7000-8000-0000000000f8",
            "itemName": "Flan",
            "itemStatus": "active",
            "variantMode": "all",
            "suspended": false,
            "isAvailable": true,
            "variants": [
              {
                "variantId": "01930000-0000-7000-8000-0000000000f9",
                "variantName": null,
                "sku": "FLA-01",
                "status": "active"
              }
            ]
          }
        ],
        "subsections": []
      }
    ]
  }
}
```

### K7 as built (no migration, baseline revision 90)

| Piece | As built |
|---|---|
| Schema | No migration and no SQL object: the evidence below shows every probe of both reads on an existing index, and the cost that misses the budget is building and sending the JSON of a large body, which no index removes. The baseline stays at revision 90 |
| Routes | `GET /catalog/outlets/{outletId}/sellable-items` (C17, `200`, `304`) and `GET /catalog/outlets/{outletId}/sellable-item-search` (C19, `200`); both behind `SessionGuard` and `TenantContextGuard`, every error as Problem Details: `400 · foundation.validation_failed` and `400 · collection.cursor_*`, `403 · tenant.context_rejected`, `403 · catalog.action_forbidden`, `403 · authorization.scope_unavailable`, `404 · organization.structure_not_found`. C10 for the till is the `categories` of C17 (the K3 note: the point-of-sale reads carry the tree); `GET /catalog/categories` stays a back-office read |
| Authorization | `catalog.sellables.view` at the outlet through one gate shared with C18 (`application/till/till-gate.ts`), decided before the outlet is looked up: a refused caller gets the same plain `403` for its own outlet's neighbour, an unknown, a closed and another organization's outlet; only a caller holding the permission organization-wide learns `404 · organization.structure_not_found`. Kitchen, bar, `catalog.items.view` alone and no role are refused; cashier and waiter scoped to their outlet read it. A member anchored at a business unit is refused at an outlet no unit operates today (no unit resolves). Never gated by the plan; reading records nothing. The permission and the statements that resolve the business unit of the outlet (the till read, the search and the barcode scan C18) run in one read-only `REPEATABLE READ` transaction (`TenantTransactionRunnerService.runReadOnlySnapshot`, the same `BEGIN` statement), so a reassignment of the outlet that commits between the permission and the read can never serve a caller anchored only at the old unit the exclusive items of the new one: the permission was judged on the snapshot the read uses. `catalog.sellables.resolve` and `admit` take the unit from their caller and decide no permission, so they have no such window |
| Sellable rule | One SQL fragment for the till lists and the scan (`infrastructure/reads/sellable-rule.queries.ts`, review S1): an active item of the default catalog, listed by the unit that operates the outlet on its site's date (the K6 predicate of the item list, now `listedAt`, drift-guarded against `variant_is_listed` and `listed_items`; *Decided*: not a call to `catalog.listed_items`, whose PL/pgSQL body the planner cannot see into and which returns every listed pair of the unit, 21.5 ms on 20,000 items in K6), with at least one active variant, and with no item-level required offer (`minSelections > 0`) whose offer or group is not active or whose available options are fewer than its minimum (since F1: whose available options cannot reach its minimum in units, the sum of their `maxQuantity`). Since B2b also with no live required combo group (`bundleComposable`) that is suspended or whose available choices (active choice, active variant of an active item) cannot reach its minimum in units, the sum of their `maxSelectionCount` (999 when absent): the one rule B1 answers as `isSatisfiable`; since F1b a choice whose component is a combo counts only when that combo can be composed too, down to the third combo level ("F1b as built" under "B2 as built"); since F1c a choice counts only when its component would be sold on its own at the same unit by this rule, listing and required modifiers included ("F1c as built"). An option is available when it is active, its group is active and its `catalog_variant` target variant is active (since K8 also the target variant's item, see "K8 as built"; since K16b the target must also be sellable at the unit and outlet of the reader, the way a combo component is: listed, not hidden, composable when it is a combo and configurable, see "K16b as built"). The category hierarchy is not part of the rule (rule 8). C18 (`MATCH_AT_OUTLET`) applies the same fragment plus the scanned variant being active, instead of `catalog.variant_is_listed` alone: before the review a scan still resolved an item whose required group had lost its options, which the lists left out; a non-sellable item answers `data: null` exactly like an unknown code, in the same single statement (budget still 7). `test/e2e/catalog-till-parity.e2e-spec.ts` proves the scan and the sellables read agree on every case of the rule
| C17 read model | `data: { businessUnitId, categories[], items[], modifierGroups[] }`, `meta: { catalogVersion, truncated, omittedItems }` (review A1: bounded lists answer `truncated` in `meta`, as `FOUNDATION_HTTP_AND_OBSERVABILITY.md` and the sibling reads do). `omittedItems` is the warning counter C13 asks for: how many items the outlet would otherwise sell that are left out because a required group cannot be filled, counted in the same statement over every candidate (not only the first 2,000), never naming them. Names follow the API's identifiers (`itemId`, `variantId`, `categoryId`, `parentCategoryId`, `modifierGroupId`, `optionId`) instead of the sketch's `id`; no codes, SKUs, descriptions, barcodes or prices, as decided. `categories` holds the effectively active categories (descending from active roots through active children only, rule 2), depth first by `sortOrder`, lowercase name and id, and only those that hold something the outlet sells now (*Decided* by the owner, OV25): an item sold by the outlet whose primary category, when effectively active, is the category or one of its descendants, so a category whose items are all hidden at the outlet, delisted from the unit, inactive or archived, or that has no items, is not sent, and the ancestors that lead to a category that is sent are. A secondary classification does not count, because the till places an item only under its primary category. The same statement decides the items and the categories (the set of items sold is the one that feeds both), the tree covers the whole outlet and not the first 2,000 items, and the entity tag moves with every change that empties or fills a category because each of them moves the catalog version or the outlet's menus; there is no parameter to show empty categories, the till shows its own message from what it receives. An item's `categoryId` is its primary category when that category is effectively active, otherwise `null` (the till's "Other"). An offered group without an available option leaves the item (and a required one omits the item); `defaultOptionIds` are the offer's defaults in option order, filtered to available options. `modifierGroups` defines every available group offered by any sellable item of the outlet, not only by the first 2,000 (*Decided*: a truncated till then has the definitions of the groups that C19 items name; bounded by the 500-group limit). At most 2,000 items in name order (`name_search_key`, id), `meta.truncated` when more are sold |
| C17 tag and caching | Strong `c<catalog version>-s<structure version>-<business unit or none>`, `Cache-Control: private, no-cache`. The use case receives an `isCurrent` check built from `If-None-Match` (weak comparison); after the outlet statement it answers `304` with no further SQL. A `200` builds its tag from the versions read in the same statement as the data, so the tag and `meta.catalogVersion` always name the body. An outlet with no unit today answers `200` with empty lists and a tag ending in `-none` | A refused caller who sends a current tag gets the plain `403`, never `304`, because the tag is compared only after the permission (proven over HTTP); every dependency of the tag has a test: a catalog write, another organization's write (no change), an outlet assigned to a unit and a unit renamed (structure version)
| C19 | `q` is optional (*Decided*: without it the pages walk everything the outlet sells, the fallback the scenario row "C19 pages through all of them" needs); with it, a prefix of the name, the code or the SKU of an active variant with the C2 keys (review S2: a retired variant's SKU finds nothing at the till, while the back-office list keeps matching any SKU) (`catalog.search_key`, literal `%`, `_` and backslash). `barcode` is not built (*Decided*: C18 already answers a scan with the exact variant; sending it answers `400`). The repository's `after`/`before` cursors replace the sketched `cursor`; the binding holds the route, the organization, the outlet, the unit operating it today and `q`, and is decoded only after the permission and the outlet. Item shape of C17; `limit` 1 to 100, default 50; no ETag. A hidden item and an unknown name both answer an empty page |
| Statement budgets | C17 `200`: 8; C17 `304`: 7; C19: 8 (session, membership, `BEGIN`, tenant context, permission, outlet state, the read, `COMMIT`), proven by `test/e2e/statement-budget.e2e-spec.ts`. The design's "6" for the `200` counted three reads after three statements; the read is one statement so that the data, the tree, the groups and the version share one snapshot |
| Offline readiness | Every till read answers the organization-wide catalog version it was read at as `meta.catalogVersion` (an integer, read in the same statement as the data): a client syncing the pages of one walk can tell that the catalog changed between pages, and a future snapshot or "changes since version N" feed can reuse it. Item, variant, group, option and category ids are stable UUIDs, the search pages by keyset cursor, and the answer is already the authorized subset of one outlet. Not built: the snapshot, the delta feed and a device-scoped grant. Per `BUSINESS_DESIGN.md` §4 and D06 only Sales commands replay offline; catalog writes stay online |
| Index evidence | `EXPLAIN (ANALYZE, BUFFERS)` as `ninaku_runtime` under row level security, local. The `perf:http` world (2,000 items, 150 groups of 7 options, one root inactive): the 925-item outlet C17 15.5 ms (27,519 buffers), the 300-item restaurant outlet 9.6 ms (16,710), C19 pages 1.8 to 3.5 ms. A scratch world of 20,000 items × 3 variants × 5 offers on two items of three, half listed: C17 88.8 ms (203,559 buffers, 2,001 of about 9,000 sellable items answered), C19 first page 3.9 ms, after a cursor 3.5 ms, prefixes 5.2 and 9.2 ms. Every probe uses an index: `catalog_items_name_search_idx` and `catalog_items_code_search_idx` for the organization, catalog and status, the unique `(business_unit_id, item_id)` of `business_unit_items`, `item_variants_item_status_idx`, `item_modifier_groups_item_idx`, `modifier_options_group_status_idx`, `item_categories_pk` and the primary keys of `modifier_groups` and `modifier_options`. History: the first formulation evaluated the sellable rule again for every offered group (768 ms and 2.5 million buffers on 20,000 items), fixed by materializing the sold set; a set-based rewrite of the item enrichment was tried in the review and reverted because the planner, estimating one row per CTE, rescanned the grouped offers for every item (320 ms over HTTP); the review then replaced the joins on `modifier_groups`, which the planner hashed again for every item, with primary-key lookups (925 items: 33.7 to 15.5 ms; restaurant: 21.6 to 9.6 ms). A local single-machine run, not a capacity claim
| Plan stability | The plans of C17, C19, the scan and `resolve`/`admit` must not depend on fresh statistics. Right after an organization imports a few thousand items, the table statistics still describe the old data (on a large shared table autoanalyze waits for 10 % of all its rows to change), the planner estimates about one row for that organization, and it joined every existence check of the sellable rule (an active variant, a configurable variant, an offer, a group with an available option) as a semi join whose inner side scanned all of the organization's variants or offers once per candidate item; the C19 page joined `catalog.items` back the same way and computed the enrichment of every scanned row before filtering it. In CI the search pages grew from 284 ms to 4.8 s and then answered `503` (statement timeout `57014`). *Decided*: every existence check in `sellable-rule.queries.ts` and the till statements is written as `anyRowOf`, a scalar `coalesce((SELECT true … LIMIT 1), false)` that PostgreSQL never turns into a join, so it runs per row with the item, variant, offer or group key as the leading index condition; the page carries the item columns from the ordered scan instead of joining `catalog.items` again; the primary images are scalar lookups on their partial unique indexes; a variant override names its `item_id` (equal by the composite foreign key) so `item_modifier_groups_live_idx` is used by its prefix; and C17 collects the offered groups per sold item (`offered`) instead of a semi join over the sold set. No migration and no new index: every probe leads with columns of an existing index. Responses, ordering, statement budgets and tags are unchanged. Evidence (`EXPLAIN (ANALYZE, BUFFERS)`, local, 2,001 items imported after `ANALYZE` beside a neighbour of 20,000 variants): before, the C19 page grew with its position from 51 to 84 ms on the first page to 1,048 ms on the sixteenth (665,074 buffers) and C17 took 380 ms (524,647 buffers), against 2 ms and 23.5 ms with fresh statistics; with offers on 6 groups the walk hit the 5 s statement timeout. After, C19 pages take about 1.5 ms (1,280 buffers) and C17 23 ms (25,163 buffers), the same with stale or fresh statistics; with 200 groups, C19 4 ms and C17 68 ms either way (the previous SQL, fresh: 3.8 to 4.3 ms and 93 ms). `till-read.plan.integration-spec.ts` reproduces the import and bounds the rows and buffers of every C19 page and of C17; it fails on the previous SQL |
| Performance | Classified hot and measured (`docs/operations/PERFORMANCE_BASELINE.md` §14.4), one client p50/p90/p95/p99: the `304` 1.82/1.98/2.10/2.57 ms; the search 5.0 to 7.6 ms p95 and below; the `200` of C17 23.99/25.41/25.71/28.28 ms on 925 items (418 KB) and 15.24/16.71/17.10/17.76 ms on the 300-item restaurant (214 KB). Both `200` rows miss only p50 and are recorded in `KNOWN_BREACHES` with their cause (one body by design; building and sending the JSON). The first K7 measurement of the 925-item row was 35.10/36.29/36.69/38.09 ms. *Decided* (review S3): any holder of `catalog.sellables.view` can loop the full `200` and only the global limiter (120 requests per minute per client IP) stops it; the `304` is the cheap path the till uses on every refresh, and no route-specific throttle is added in K7
| Still open | `CatalogSellableContract` and admission (K8, C20); a `barcode` filter on C19; whether an option whose target item is inactive should count as unavailable (closed by K8: it does not); per-variant assortment and per-variant offers; the snapshot and delta feed for offline tills (the feed is written since K12a; serving it is K12b) |

### K8 as built (no migration, baseline revision 90)

| Piece | As built |
|---|---|
| Schema | No migration and no SQL object; the baseline stays at revision 90. *Decided* with `EXPLAIN (ANALYZE, BUFFERS)` as `ninaku_runtime` under row level security, local, on an organization of 2,000 items with 600 of them offering 3 groups of 6 options: the share of the version row 1.3 ms (61 buffers), the share of the unit row 0.15 ms (76), the read of one variant 3.6 ms and of 100 variants 10.7 to 11.1 ms (8,939 buffers), every probe on an index (`item_variants` and `items` primary keys, the unique `(business_unit_id, item_id)` of `business_unit_items`, `item_variants_item_status_idx`, `item_modifier_groups_item_idx`, `modifier_options_group_status_idx`, `item_modifier_group_defaults_pk`); the only sequential scans are over the handful of rows of `business_units`, `catalogs` and `modifier_groups` of a development database. A PL/pgSQL helper would hide that plan from the planner (the K7 reason for not calling `catalog.listed_items`) and add a migration for no gain |
| Contract | `CatalogSellableContract` (`src/modules/catalog/contracts/catalog-sellable.contract.ts`), an abstract class that is also the DI token, bound with `useExisting` to `CatalogSellables` (`application/sellable/catalog-sellables.ts`) and exported by `CatalogModule`, which a consumer imports from `src/modules/catalog/contracts/catalog.module.ts`. `resolve(context, { businessUnitId, variantIds })` answers `{ catalogVersion, verdicts[] }`, one verdict `{ variantId, sellable, refusal, snapshot }` per requested variant in the requested order. `admit(context, { businessUnitId, lines: [{ variantId, modifierOptionIds }] })` answers `{ catalogVersion, lines: [{ snapshot, modifierOptionIds }] }` or throws `CatalogNotSellableError` (`contracts/catalog-sellable.errors.ts`) with every refusal `{ line, variantId, reason, modifierGroupId, optionId }`; the consumer maps it to its own public code. Both run in the caller's tenant transaction (the `TransactionContext` it passes, as `TenantAuditContract`), check no permission (the consuming command authorizes itself, exactly as it does before writing its own tables) and record nothing. No HTTP route, no OpenAPI change |
| Snapshot | `variantId`, `itemId`, `itemCode`, `itemName`, `variantCode`, `variantName`, `isDefault`, `sku`, `salesUnitCode`, `minimumSalesQuantity`, `salesQuantityStep`, `maximumSalesQuantity` (exact decimals as strings) and `modifierGroups[]`: the item-level offers whose offer and group are active and which keep an available option, in offer order, each `{ modifierGroupId, name, minSelections, maxSelections, defaultOptionIds, options[] }` with the available options `{ optionId, name, kind, variantId, maxQuantity }` in option order and the defaults filtered to available options (the C17 rules). A known variant that is not sellable keeps its snapshot (Pricing configures what is not on sale yet); an unknown one answers `snapshot: null`. `catalogVersion` is the organization's version read in the same statement, the number C17 and C19 answer as `meta.catalogVersion`, so a sale can store which catalog it was admitted against (offline replay, D06) |
| Refusals | A closed domain, published from `domain/sellable/sellability.ts`, named in this order (the first failing fact wins): `unknown` (no such variant in the caller's organization: another organization's variant is the same answer, RLS hides it), `business_unit_unavailable` (unknown, another organization's, inactive or malformed unit: one answer), `item_archived`, `item_inactive`, `variant_archived`, `variant_inactive`, `assortment_none` (the unit sells nothing from the catalog), `not_listed` (delisted under `all`, not listed under `selected`), `required_modifier_unsatisfiable`, and since B2b `bundle_group_unsatisfiable` (a combo with a required group that is suspended or whose available choices cannot reach its minimum; `admit` explains it per group at its path instead). The verdict itself is never derived from these facts: `sellable` is the shared rule, and the facts only name the reason (a refusal no fact explains answers `not_listed`). `admit` adds, per line: `option_not_offered` (not an option of an active item-level offer of that item: an unknown option, another item's option, another organization's option), `option_unavailable` (offered, but inactive, in an inactive group or pointing to an inactive variant), `selection_below_minimum` and `selection_above_maximum` (per offered group, counting the chosen available options) |
| Sellable rule | The same SQL fragment as the till lists and the scan (review fix: an option whose `catalog_variant` target variant is active but whose item is archived or inactive is unavailable everywhere, the till lists, the scan, the contract and the C13 offer command, which now judges defaults with the same `availableOption`; at the time of this review the target did not have to be listed at the unit (K16b changed that: the target is judged like a combo component); statement budgets unchanged: C17 8 and 7, C19 8, C18 7, `resolve` 1): `sellableItem` of `infrastructure/reads/sellable-rule.queries.ts` plus the variant being active, the exact composition of C18 (`MATCH_AT_OUTLET`). The diagnosis reuses its pieces (`listedAt`, the new `assortmentModeAt` it is built on, `requiredGroupsSatisfiable`), and the snapshot reuses the till's offer pieces (`offerDefaults`, `groupHasAnAvailableOption`, `availableOption`, moved to the same file). A unit test pins the fragment inside the statement, and `test/e2e/catalog-till-parity.e2e-spec.ts` now compares the scan, the sellables read and the contract on every case of the rule |
| Lock order | `resolve` takes no lock. `admit` takes, in this order: the organization's `catalog.catalog_versions` row `FOR SHARE`, then the business unit row `FOR SHARE`, then reads (3 statements; `resolve` is 1). Every catalog command takes the version row `FOR UPDATE` first, and every catalog table, `item_modifier_group_defaults` included, carries statement-level bump triggers that update that row, so the shared version row fences every catalog change of the organization, even a write outside any command (shared per-row locks could not fence an insert such as a new delisting row or a new required offer). The unit row `FOR SHARE` blocks any update of that unit's row while the admission is open: its deactivation by the Organization module, which never touches the catalog version, but also a rename or anything the structure version trigger bumps. `admit` never reads defaults. *Decided*: no per-row `FOR SHARE` on items, variants or options (the design's sketch through `catalog.assert_variant_listed`): the version row already excludes every change those locks would, and locking every row of a sale would dirty pages and create multixacts on the sales path for nothing. The application never calls `catalog.assert_variant_listed` (it stays in the database because the order-line trigger of the Sales schema, `database/canonical/0110_sales.sql`, calls it; `CatalogSellableContract` is the in-process path): its rule lacks the default catalog and the required groups of K7, it raises one generic `23514` per variant and it needs one statement per variant |
| Consequence | PostgreSQL does not queue a `FOR SHARE` behind a waiting `FOR UPDATE`, so a sale never stalls behind a pending catalog edit: new admissions keep taking the shared version row while the command waits. The cost is the reverse: a catalog command of the organization (any item, any unit, deactivating a sold-out dish during a rush included) waits until no admission of the organization is open, and under continuous overlap of sale transactions in a multi-unit organization it fails with `57014` after `DATABASE_STATEMENT_TIMEOUT_MS` (5,000 ms by default, `environment-variables.schema.ts`). *Correction (measured on 2026-10-06)*: earlier text said it failed after `DATABASE_LOCK_TIMEOUT_MS` (2,000 ms) with a lock timeout; PostgreSQL applies `lock_timeout` to each lock acquisition separately, and a command waiting on a version row shared by several sales waits on each sale's transaction in turn, each wait far below 2 s, so the lock timeout never fired in 1,209 measured commands and the statement timeout is the real limit. Two admissions never wait for each other; admissions and commands never deadlock; an admission that arrives while a command already holds the version row waits and then judges what that command committed, never a half state |
| Requirement for Sales | Call `admit` last in the sale's transaction, right before the sale's own writes, and commit at once: the time an admission is held is the time every catalog writer of the organization is blocked. Keep the transaction open 50 ms or less after `admit` (the measured gate below), and measure that hold with `npm run perf:admission` before Sales ships |
| Gate before Sales ships | MEASURED on 2026-10-06: keep the version-row share (gate closed, with the Sales requirement above as its condition). `npm run perf:admission` (`tooling/performance/run-admission-benchmark.ts`, opt-in, never in `verify`, `npm test` or CI) boots the compiled application with the production pool (10 connections) and timeouts, seeds one organization with 2,000 items (1,749 sellable at a unit in mode `all`, 873 at a unit in mode `selected`, a third of them offering two modifier groups), and runs N tills in {1, 4, 16, 64} that admit 1 to 5 random lines through the real contract and hold the transaction H in {20, 50, 200} ms, with a think time of 4 × H (realistic, 20 % duty cycle) or 0 (artificial rush), while one writer cycles an item deactivation and reactivation, a delisting and listing and an assortment mode change through the real use cases, 20 s per cell. Results (local, one machine under load, not production capacity; `docs/operations/PERFORMANCE_BASELINE.md` §15): 43,210 admissions, none failed, `admit` p99 at most 62.4 ms in every cell, so sales never waited; zero lock timeouts in 1,209 commands; in the realistic profile up to the 16 tills the pool serves, the writer succeeded 100 % at every hold, with its p99 growing to 776 ms (N 16, H 200) and one command of 4.6 s (N 16, H 50); with H at most 50 ms it succeeded 100 % in every cell, rush included (p99 up to 3.1 s). It degrades only with H = 200 ms: rush 95.8 % at 4 tills and 77.8 % at 16, and realistic 93.8 % at 64 tills, whose offered load (12.8 open transactions) exceeds the pool, so it behaves like rush; each failure is a `57014` statement timeout at 5.0 to 5.4 s. Fallback, kept as the next slice only if Sales must hold the admission longer than 50 ms: a lock-free `admit` that reads once like `resolve`, stores `catalogVersion` with the sale and relies on Sales' own foreign keys to `catalog.item_variants` and `catalog.modifier_options` (`FOR KEY SHARE`), never per-row shares |
| Input | At most 100 variants per `resolve` and 100 lines per `admit` (the page maximum of the API), at most 100 options per line; an empty request, a repeated variant in `resolve` or a repeated option in a line throws `CatalogSellableRequestInvalidError` with `empty`, `too_many`, `too_many_options` or `duplicate` before any SQL. The same variant may appear on several lines of one admission (two burgers cooked differently). Ids are compared lowercase; a malformed variant id answers `unknown` and a malformed unit `business_unit_unavailable`, neither reaching SQL |
| Known defect (2026-10-07; fixed: the quantities by K9d, the offer id by K9e) | `admit` takes `modifierOptionIds` per line, so it cannot carry the quantity of an option and judges each group on the count of distinct options, while the Sales schema stores a quantity per selected option and judges groups on the sum of quantities (`0110_sales.sql:4229-4247`); it also does not answer the offer id (`item_modifier_groups.id`) Sales must store. Fixed by slices K9d and K9e (no migration), the quantity-aware admission with the effective offer identity of [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §5.1, after the stable offer identities of K9a–K9c (architect's order), which supersedes the "reported, not judged" note below |
| Decisions where the design was silent | `admit` takes lines instead of one variant, so a sale admits all its lines in one call and one read. The chosen options are the final selection: `defaultOptionIds` are what the till preselects, and `admit` never adds them (the till sends them when it keeps them). The quantity of an option (`maxQuantity`) is reported, not judged: the option ids of a line are a set, and how many of one option a line carries belongs to Sales (superseded by K9d: the line carries each option's quantity, judged against `maxQuantity`, and the groups on the sum of units) |
| Proof | `src/modules/catalog/domain/sellable/sellability.spec.ts`, `src/modules/catalog/application/sellable/catalog-sellables.spec.ts`, `src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts`; over PostgreSQL through a consumer module that sees only the contract (`test/fixtures/catalog-sellable/`): `test/e2e/catalog-sellable-contract.e2e-spec.ts` and `test/e2e/catalog-sellable-concurrency.e2e-spec.ts`; the boundary for a future Sales module in `tooling/repository/check-module-boundaries.spec.ts`. See [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) |
| Still open | The Sales and Pricing consumers (and the public code Sales gives `CatalogNotSellableError`); the hold time of Sales' real transaction, measured with `npm run perf:admission` before Sales ships, and the lock-free `admit` only if that hold exceeds 50 ms; the till's offline snapshot (K12b) and delta feed (written since K12a, "K12a as built"); per-variant offers. `catalog.assert_variant_listed` and `catalog.resolve_barcode` stay with their grants and are not to be retired: no application path calls them, but the order-line trigger of the Sales schema (`database/canonical/0110_sales.sql`, created by the canonical install although no module uses Sales yet) calls `assert_variant_listed`, and the database contracts (`topology-catalog-contracts.ts`, `catalog-foundations-contracts.ts`, `prepare-database-contracts.ts`) exercise both; they remain the database-level admission of that trigger until Sales is built, and `CatalogSellableContract` is the in-process path |

### Idempotent creates as built (migration `0081_catalog_creates_remember_their_request`, baseline revision 91)

| Piece | As built |
|---|---|
| Why | Offline readiness and the owner's "close all pending": a retry after a lost response must return the first result. Before, a retried category, group or option create answered `409 · catalog.code_taken` (the client could not tell its own success from a real clash), and a retried item create answered `409 · idempotency.key_reused` once the item had been edited. The debt recorded in "Hardening round as built" and the open decision of "Leftovers closed" are closed here |
| Schema | One migration, expand only: `catalog.categories`, `catalog.modifier_groups` and `catalog.modifier_options` gain `creation_idempotency_key uuid` with the partial unique indexes `catalog_categories_creation_idempotency_uq`, `catalog_modifier_groups_creation_idempotency_uq` and `catalog_modifier_options_creation_idempotency_uq` on `(organization_id, creation_idempotency_key) WHERE creation_idempotency_key IS NOT NULL`, exactly like `catalog_items_creation_idempotency_uq`; the four tables gain `creation_request_hash text`, constrained by `<table>_creation_request_hash_chk` (`items_`, `categories_`, `modifier_groups_`, `modifier_options_`) to be `NULL` or a 64-character lowercase hex digest stored next to a key, so a malformed hash or a hash without a key is refused by PostgreSQL itself (the item create always writes both; a key without a hash stays legal for the items created before revision 91). The checks can never fail on existing rows, whose new column is `NULL`. Nothing is dropped or renamed and every new column is nullable, so the previous API keeps working during the rollout (it writes neither column, and rows without a key never collide). The indexes are built inside the migration transaction, which briefly blocks writes on those tables, acceptable while catalogs are small (as `0076`). Grants are unchanged: `ninaku_runtime` holds `SELECT, INSERT, UPDATE` on the whole tables, which covers the new columns (checked with `has_column_privilege`); row level security is unchanged. Proven by `database/tests/contracts/catalog-creation-request-contracts.ts`, and by an upgrade of a populated revision 90 database (2,000 items with keys, 2,000 categories, 500 groups, 3,000 options): the first `db:prepare` applies `0081`, keeps every row and leaves the item keys without a hash, a second `db:prepare` changes neither the schema nor the ledger, and the column order equals a fresh install of the canonical |
| Request hash | `requestFingerprint` (`src/foundation/idempotency/request-fingerprint.ts`): the lowercase hex SHA-256 of the canonical JSON of the request, keys sorted by code unit at every depth, arrays in their order, absent fields omitted and `null` kept. It is computed in the application layer over the validated, normalized request, never over the raw body: the item create hashes its draft with the categories sorted by id and the quantities in their shortest form (`itemCreationRequest`), so the same categories in another order or `1.0` for `1` still replay; the category and group creates hash their draft; the option add hashes `{ modifierGroupId, option }`, because the group in the path is part of the request. The hashed request is typed as a plain JSON value (one builder per create that names every field of its draft, so a new draft field cannot be left out of the hash), and the fingerprint throws on a value JSON cannot represent exactly (a `Date`, a `Map`, a `Set`, a non-finite number, `undefined` inside an array) instead of hashing it as something else. The body itself is never stored, and neither the hash nor the key is ever answered or recorded |
| Semantics (the four creates) | `resolveIdempotentCreation` (foundation) under `createIdempotently` (`application/catalog-creation.ts`): after the admission and the catalog version row, the key is looked up in the module's own table by organization and key. Found with the same hash: `201`, the resource as it is now (even after an edit) with its current ETag and `Idempotent-Replayed: true`, nothing written or recorded. Found with another hash: `409 · idempotency.key_reused`. Found without a hash, which only items created before revision 91 can be: compared with the row as it is now, as before (`matchesCreationIntent`); the other tables never stored a key before revision 91 and pass no comparator, so there a key without a hash, which only a manual write could leave, answers `409 · idempotency.key_reused`. Not found: the create runs and its `INSERT` claims the key with `ON CONFLICT (organization_id, creation_idempotency_key) WHERE creation_idempotency_key IS NOT NULL DO NOTHING`; a lost claim reads the stored creation and replays or refuses. The lookup comes before the category and group limits, the parent, the variants and the codes, and before the `If-Match` of the option add, so a replay never answers a refusal the first call did not. `Idempotency-Key` is required on the four creates, the rule C4 already had (`400 · foundation.validation_failed` when missing or not a UUID v4 or v7); no client existed, so requiring it broke none. A key is scoped to the organization and to one kind of create: the same key in another organization, or on a create of another kind, is independent. A different key with a taken code still answers `409 · catalog.code_taken` |
| Concurrency | Every create takes the catalog version row `FOR UPDATE` before the lookup (lock order, §5), so parallel retries of one key run one after the other: the first creates and the others find its committed row and replay, with one record and no `500`; parallel requests that share a key with different bodies answer one `201` and `409 · idempotency.key_reused` for the others. For items, categories and groups the `ON CONFLICT` claim is a second line of defence (a mutation that skips the lookup still replays them); the option add relies on the lookup, because its in-group code check runs before the insert, which the version row makes safe |
| One category and its tag | `GET /catalog/categories/{categoryId}`: `catalog.items.view` organization-wide or at `businessUnitId`, the gate of every catalog read (a plain `403 · catalog.action_forbidden` before any lookup, the same `404 · catalog.not_found` for an unknown or foreign category). It answers the category with its depth, its own status and its effective visibility, a strong ETag and `Cache-Control: private, no-cache`, and `304` with no body to a matching `If-None-Match` (weak comparison). The tag (`categoryEntityTag`) is the SHA-256 of exactly that representation, in a fixed field order. *Decided:* the derived `isEffectivelyActive`, `blockedByAncestorId` and `depth` belong in the hash. A strong validator must change whenever the representation changes (RFC 9110 §8.8.1); leaving them out would let a `304` confirm a visibility or depth that an ancestor's deactivation or move made stale, which is exactly what the screen shows. The price is that such an ancestor change invalidates its descendants' tags, so a write sent with the old tag answers `412` and the client reads the category again; that is rare, cheap and never loses data. Edits of other categories that do not hide or move it, and unrelated catalog writes, leave the tag unchanged, unlike the organization-wide tree tag, which is unchanged |
| Documented answer headers | The `201` of the four creates documents `Cache-Control: private, no-store` and `Idempotent-Replayed: true` (present only on a replay) in OpenAPI; their `ETag` is described in the operation text, as for every tagged write, because the semantic check reads a documented `ETag` header on a success as a conditional read |
| Category writes | Create, edit, re-parenting, deactivation, reactivation and archival (since API v7 PR 3: create, `PATCH` with the move, and the transitions route, which answers `{ categoryId, status }` with the same tag) answer the category with that tag and `Cache-Control: private, no-store`; the five writes on an existing category accept an optional strong `If-Match`, compared right after the catalog version row, the category row `FOR UPDATE` and one read of the category and of the hierarchy (only when the header is sent): a stale or weak tag answers `412 · foundation.precondition_failed` and nothing is written or recorded; without the header the write is not conditional, never `428` |
| Proof | `src/foundation/idempotency/request-fingerprint.spec.ts`, `src/foundation/idempotency/resolve-idempotent-creation.spec.ts`, `src/modules/catalog/application/categories/create-category.use-case.spec.ts`, `src/modules/catalog/application/modifiers/modifier-creations.use-case.spec.ts`, `src/modules/catalog/application/categories/category-entity-tag.spec.ts`, `src/modules/catalog/application/items/create-item.use-case.spec.ts`; over PostgreSQL `test/e2e/catalog-idempotent-creates.e2e-spec.ts`, `test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts`, `test/e2e/catalog-category-preconditions.e2e-spec.ts` and `test/e2e/catalog-items.e2e-spec.ts`. Mutations of the hash comparison, of the stored-hash path, of the claim mapping, of the category `If-Match` and of each field of the category tag fail these tests; a lookup that ignores the organization survives them, because row level security already scopes it (the index scope is proven by the database contract). See [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) |
| Not built | Variant, matrix and barcode adds keep no key: their code or value is required and unique, so a retry answers `409` without duplicating (K4). Catalog writes other than these creates stay online only (D06) |

### Contract step and where-used as built (migration `0082_catalog_contract_step_and_offers_by_group`, baseline revision 92)

| Piece | As built |
|---|---|
| Why | The owner's "close all pending": the expand step of the hardening round left `catalog.modifier_options.is_default` in place for the previous API, and K5 left the where-used read of a group unbuilt, so a manager could not see which items a deactivation or an archival of a group or an option would touch |
| Contract step | Migration `0082` runs `ALTER TABLE catalog.modifier_options DROP COLUMN IF EXISTS is_default` inside the claim of the repository's migrations, so it can run again. Safe: no statement of the running code reads or writes the column since revision 90 (a unit test checks every modifier statement), the previous API (before PR 512) is long gone, and production holds no catalog data yet. `rg` over `database/canonical` and `database/access` found one reference to the option column, its definition in `catalog.modifier_options` (`0080_catalog.sql`), and a query of `pg_proc`, `pg_views`, the indexes, constraints and triggers of the table confirmed it: no function, view, index, trigger, constraint or seed named it; outside the schema only the database contract of `0080` and a unit-test title named it, both updated here (in PostgreSQL 18 its `NOT NULL` was a `pg_constraint` row, dropped with the column). Every other `is_default` belongs to variants, catalogs, bundle group items, or the fiscal, production, commerce and cross-domain tables, and stays. The canonical `0080_catalog.sql` is the end state without the column |
| Index | `item_modifier_groups_group_idx (modifier_group_id, organization_id)`, plain, in the same migration (`CREATE INDEX IF NOT EXISTS`). *Decided* with `EXPLAIN (ANALYZE, BUFFERS)` as `ninaku_runtime` under row level security on two organizations of 2,000 items and 150 groups, one group offered by 1,500 items (15,009 offers in all): without it the offers of a group were a sequential scan of every organization's offers (13,509 rows removed, 1,001 buffers); with it a bitmap index scan (3 index buffers), the first page in 3.7 ms and a later page in 0.9 ms, and the group lookup an index-only scan. It also serves `item_modifier_groups_group_organization_fk`, the foreign key of an offer to its group, which had no index leading with the group |
| Upgrade | Proven locally on a revision 91 database holding `is_default = true` rows: one `db:prepare` dropped the column and added the index, the rest of the schema (`pg_dump --schema-only`) and the options unchanged, and the result equal to a fresh install at revision 92; a second `db:prepare` changed nothing |
| Route | `GET /catalog/modifier-groups/{modifierGroupId}/offers`: every live offer of the group: the item-level rows `PUT /catalog/items/{itemId}/modifier-groups` writes and, since K9g, the active override of a variant (an opt-out is not a use of the group and is left out), each `{ itemId, variantId, code, name, status, minSelections, maxSelections, sortOrder, defaultOptionIds }` (`variantId` null for the item-level offer), ordered by the item name key and then the offer id with the defaults in the order of the group. *Decided:* `status` filters the items and, absent, answers every item whatever its status, archived ones included, like `GET /catalog/modifier-groups` answers every group: the read is about impact and history. A group of any status answers; one no item offers answers an empty page |
| Paging and cache | Keyset pages ordered by the item's `name_search_key` and then its id (`limit` 1 to 100, default 50, `after` / `before`, `meta.pageInfo`), the signed opaque cursor bound to the route, the organization, the group, `status` and `businessUnitId`. No `ETag` (a page of a list), `Cache-Control: private, no-cache` |
| Authorization | The gate of every catalog read: `catalog.items.view` organization-wide or at `businessUnitId`, checked before the cursor is decoded and before any lookup, so a caller without it gets the plain `403 · catalog.action_forbidden` whatever the group, the unit or the cursor; an unknown group and another organization's group answer the identical `404 · catalog.not_found` (row level security hides the latter); only an organization-wide reader learns `404 · organization.structure_not_found`. Never plan-gated; no write, no record |
| Statements | Two: the group lookup by id and one page of the offers; the item read (C3) reuses the same defaults fragment (`offerDefaultIds`) |
| Proof | `src/modules/catalog/application/modifiers/read-modifier-groups.use-case.spec.ts`, `src/modules/catalog/infrastructure/modifiers/modifier-group-offers.queries.spec.ts`, `src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts`; over PostgreSQL `test/e2e/catalog-modifier-group-offers.e2e-spec.ts` and `test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts`; the column, the replayed copy of `0080` and the index in `database/tests/contracts/catalog-offer-default-migration-contracts.ts`. Looking the group up before the gate, and a cursor binding without the group, the status or the unit, each fail these tests. See [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) |
| Correction | Earlier text (K6, K8, risk R8) said `catalog.assert_variant_listed` and `catalog.resolve_barcode` were called by no runtime path and could be retired. Not so at database level: the order-line trigger of the Sales schema (`0110_sales.sql`) calls `assert_variant_listed`, and the database contracts and the runtime grants cover both. They stay as the database-level admission of that trigger until Sales is built; `CatalogSellableContract` is the in-process path |
| Performance | `GET /catalog/modifier-groups/{modifierGroupId}/offers` is classified frequent and not measured by `perf:http` (the scenarios seed no item offers yet); the evidence is the `EXPLAIN` above, local only |

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

The first slice of the Catalog API v7 plan: HTTP rules every later slice
reuses. No route is added, renamed or removed.

| Item | As built |
|---|---|
| Required `If-Match` | A write that replaces a whole collection must name the version it replaces: `PUT /catalog/items/{itemId}/categories`, `PUT /catalog/items/{itemId}/images`, `PUT /catalog/items/{itemId}/variants/{variantId}/images`, `PUT /catalog/items/{itemId}/modifier-groups`, `PUT /catalog/items/{itemId}/variants/{variantId}/modifier-groups`, `PUT /catalog/items/{itemId}/bundle-groups` and `PUT /catalog/menus/{menuId}/structure`. Without `If-Match`, with a blank one or with `*` they answer `428 · precondition.required` (Problem Details whose detail says to read the resource and send its ETag) and nothing is written or recorded; a stale tag still answers `412 · foundation.precondition_failed`, judged by each use case after its locks, exactly as before. One foundation decorator, `@RequiresIfMatch()` (`src/foundation/http/conditional-requests/requires-if-match.decorator.ts`), holds the guard and its OpenAPI (the header `required: true`, the `412` and the `428`); no use case checks the presence. The guard runs after the session and tenant guards and before validation, so a missing session answers `401`, a rejected tenant `403`, and then a missing `If-Match` `428` before any `400`, `403` or `404` of the use case. The bulk `PUT /catalog/item-images`, the creates, the lifecycle routes, `PATCH` and `PUT /catalog/business-units/{businessUnitId}/assortment` keep an optional `If-Match` |
| Replays | Sending the list a resource already has, with its current tag, still answers `200` with the same tag and records nothing. Two writes sent at once with the same tag now end in one `200` and one `412` instead of two `200`s: the second names a version that no longer exists |
| Closed `meta` | The shared `Envelope` no longer declares an open `meta` (`additionalProperties: true` with an optional `pageInfo`). Each route closes its own: a route that declares no meta documents `meta` as `{ type: object, additionalProperties: false, maxProperties: 0 }`, a route that declares keys lists exactly those (required, closed), and only a paged collection declares `pageInfo`. Bodies did not change: the interceptor still answers `meta: {}` by default, so no ETag moved. `meta.truncated` left `GET /catalog/categories` and `GET /catalog/modifier-groups`: each list is capped at its creation limit (500), so it could never be true. `test/e2e/envelope-meta-contract.e2e-spec.ts` validates real bodies of eight catalog routes (create, read, a replacement `PUT`, the item list, the tree, the group list, the workspace and the till read) against their documented schema, and shows that one undeclared meta key is refused |
| Writes per member | Every catalog route that is not `GET`, `HEAD` or `OPTIONS` counts toward the writes one member may send a minute, all catalog writes together: `MEMBER_WRITE_RATE_LIMIT_PER_MINUTE`, 60 by default, keyed by the membership of the tenant context (the client address when there is none). The next write answers `429 · rate_limit.exceeded` with `Retry-After`; reads never count, and another member of the same organization keeps writing. A refused write counts too, so a member without the permission leaves at most that many denied audit records a minute (security findings S1 and S4). The counter is in memory per process: several API instances would each count apart (see `FOUNDATION_HTTP_AND_OBSERVABILITY.md` §11.B). Proof: `src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts` and `test/e2e/catalog-member-write-throttle.e2e-spec.ts` |
| Body ceilings | A JSON body is bounded at 100 KB everywhere (`413 · payload.too_large` beyond it), except where the documented maximum does not fit: `PUT /catalog/menus/{menuId}/structure` takes up to 2.25 MiB (its maximum, 50 sections and subsections, 1,000 entries and 50 selected variants each with the longest texts, is 2,143,242 bytes of compact JSON) and `PUT /catalog/items/{itemId}/bundle-groups` up to 256 KiB (20 groups of 50 choices: 214,358 bytes). Each ceiling is the smallest 256 KiB step that holds its maximum; `PUT /catalog/item-images` (57,111 bytes), `POST /catalog/items/{itemId}/variant-matrix` (12,446 bytes) and the assortment listings and delistings (7,813 bytes) fit the default and keep it. The ceilings live in `presentation/controllers/shared/catalog-body-limits.ts`, mounted by `configureHttpApplication` before the default parser. The raised parser reads a body only when the request carries well-formed `Authorization: Bearer` credentials; without them the 100 KB default applies, so an anonymous caller gets `413` past 100 KB and never makes the server read 2 MB before its `401`. Proof: `src/modules/catalog/presentation/controllers/shared/catalog-body-limits.spec.ts`, `src/foundation/http/body-limits/json-body-limits.spec.ts` and `test/e2e/catalog-body-limits.e2e-spec.ts` |
| Menu arrays | `sections` and each `subsections` array take at most 50 elements at the schema (`MENU_MAX_SECTIONS`), so a longer list is refused at `sections` or `sections[i].subsections` with `too_big` before the tree is walked; the total of 50 sections and subsections is still judged over the whole tree |
| Response headers | Every answer carries `X-Content-Type-Options: nosniff` and no `X-Powered-By` |
| Proof | `src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts`, `test/e2e/catalog-required-preconditions.e2e-spec.ts` and `test/e2e/catalog-modifiers-concurrency.e2e-spec.ts`; dropping the guard from the decorator fails both new specs. See [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md), F1 |

### API v7 PR 2 as built: item and variant transitions (no migration, baseline revision 111)

Eight routes become two (`-6`): one transitions route per resource replaces
the per-verb lifecycle routes, and choosing the default variant folds into
the item edit.

| Before | After |
|---|---|
| `POST /catalog/items/{itemId}/{deactivation,reactivation,archival,restoration}` | `POST /catalog/items/{itemId}/transitions` with `transition` one of `deactivate`, `reactivate`, `archive` or `restore` |
| `POST .../variants/{variantId}/{deactivation,reactivation,archival}` | `POST .../variants/{variantId}/transitions`, the same list, `restore` new |
| `POST .../variants/{variantId}/default-selection` with `{ formerDefaultName? }` | `PATCH /catalog/items/{itemId}` with `{ defaultVariantId, formerDefaultName? }` |

| Item | As built |
|---|---|
| One table | The closed list comes from the item lifecycle table (`ITEM_TRANSITION_VERB` and `itemLifecycleTable()` in `domain/items/item-lifecycle.ts`): the request schema, the OpenAPI description (where each transition starts and lands, and whether the plan gates it) and the refusal detail all read it. Variants use the same table |
| `TransitionRoute` | `src/foundation/openapi/api-transition-route.decorator.ts` documents any transitions route from its table: `POST`, `200`, the optional `If-Match` and its `412`, `403` (the entitlement code only when a transition is gated), `404` and `422`. `CatalogTransitionRoute` binds the catalog codes; categories, modifier groups, options and menus keep `LifecycleRoute` until PR 3 moves them |
| Answer | Lean: `{ itemId, status }` or `{ variantId, status }`, with the strong ETag of the whole item in the `ETag` header, exactly the tag `GET /catalog/items/{itemId}` answers (the item is still read whole inside the command until the per-item version arrives) |
| Refusal | `422 · catalog.transition_invalid` names the current status and the transitions it allows: `The item is archived; from archived it allows restore. Read it again and send one of those transitions.` A value outside the list, the former noun (`deactivation`) or any other field answers `400 · foundation.validation_failed` |
| Gating and audit | `deactivate` and `archive` never ask about the plan, `reactivate` and `restore` do. One record per real change, named after the transition; a variant restore is recorded as `catalog.item.variant_updated` with the status before and after, because seeding `catalog.item.variant_restored` needs a migration. This reuse is temporary: PR 3 seeds the missing catalog action codes in migration `0102` and switches the variant restore to `catalog.item.variant_restored` |
| Variant restore | `archived → inactive`, gated, never on the default (which can never be archived) and never on an archived item. Code, SKU and the place among the 500 variants were kept while archived, so nothing else is re-checked; barcodes stay inactive (archival released them) and offer defaults were already removed, so the restore deletes none. Restoring a live default answers `transition_invalid`, no longer `default_variant_required` |
| Default in `PATCH` | `defaultVariantId` switches the default under the same rules and refusals as before (`variant_not_active`, `variant_name_required`, `not_found` for a variant of another item); `formerDefaultName` alone answers `400`. A profile edit and a default switch in one request write both in one locked transaction, one record each |
| Statements | An item transition issues 10 statements (8 for a repeat), a variant transition 12 (11 for a reactivation or restore, 8 for a repeat): `test/e2e/statement-budget.e2e-spec.ts`; 14, 12, 20 and 19 before the one-statement item read (API v7 PR 6) |
| OpenAPI | 173 operations become 167 (Catalog 64 to 58). Breaking: the eight removed paths (four item verbs, three variant verbs, `default-selection`), plus a changed answer schema; the document moves to `8.0.0`, since staging already holds `7.0.0` from the foundation slice |
| Proof | The C6 and C7 rows of [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) |

### API v7 PR 3 as built: category, modifier and menu transitions (migration `0102`, baseline revision 112)

Twelve routes become four (`-8`, Catalog 58 to 50): one transitions route per
resource replaces the per-verb lifecycle routes, and moving a category folds
into its edit.

| Before | After |
|---|---|
| `POST /catalog/categories/{categoryId}/{deactivation,reactivation,archival}` | `POST /catalog/categories/{categoryId}/transitions` with `deactivate`, `reactivate`, `archive` or `restore` (new) |
| `POST /catalog/categories/{categoryId}/re-parenting` with `{ parentCategoryId }` | `PATCH /catalog/categories/{categoryId}` with an optional `parentCategoryId` (a category id, or `null` for the root) beside `code`, `name`, `description` and `sortOrder` |
| `POST /catalog/modifier-groups/{modifierGroupId}/{deactivation,reactivation,archival}` | `POST /catalog/modifier-groups/{modifierGroupId}/transitions`, the same four, `restore` new |
| `POST .../options/{optionId}/{deactivation,reactivation,archival}` | `POST .../options/{optionId}/transitions`, the same four, `restore` new; it stays per option because taking one option off sale is an everyday till task |
| `POST /catalog/menus/{menuId}/{activation,archival}` | `POST /catalog/menus/{menuId}/transitions` with `activate`, `unpublish` (new), `archive` or `restore` (new) |

```json
POST /api/v1/catalog/menus/0193…d0/transitions
{ "transition": "unpublish" }

200 OK
ETag: "<strong tag of the menu, as GET /catalog/menus/{menuId} answers it>"
{ "data": { "menuId": "0193…d0", "status": "draft" }, "meta": {} }
```

| Item | As built |
|---|---|
| Tables | Categories, modifier groups and options take the item table (`itemLifecycleTable()`); menus have their own (`menuLifecycleTable()` in `domain/menus/menu-lifecycle.ts`): `activate` draft → active (gated), `unpublish` active → draft, `archive` draft or active → archived, `restore` archived → draft (gated). Both tables run on one shared rule set (`domain/shared/lifecycle.ts`: judge, table, allowed verbs) |
| Requests | One closed list per table: `LifecycleTransitionRequestSchema` (items, variants, categories, groups, options) and `MenuTransitionRequestSchema`, both built by `transitionRequestSchema(verbs, example)` in `presentation/dtos/shared/transition-request.schema.ts`; any other value or field `400 · foundation.validation_failed` |
| Answers | Lean: `{ categoryId, status }`, `{ modifierGroupId, status }`, `{ optionId, status }` or `{ menuId, status }`, with the strong ETag of the category, the group (also for an option) or the menu: exactly the tag the read answers. `If-Match` optional, no `Idempotency-Key`; a repeat answers `200` and records nothing |
| Refusal | `422 · catalog.transition_invalid` names the status and the transitions it allows, for example `The menu is archived; from archived it allows restore. Read it again and send one of those transitions.` |
| Gating | `deactivate`, `archive` and `unpublish` never ask about the plan; `reactivate`, `restore` and `activate` do |
| Category restore | `archived → inactive` under the same parent; the code stayed unique and the category kept counting toward the 500, so only the place is judged again: `422 · catalog.category_archived` while the parent is archived (restore the parent first), `422 · catalog.category_depth_exceeded` if too deep |
| Category move in `PATCH` | The place is judged (cycle, depth, archived parent, unknown parent `404`) before anything is written; the profile edit and the move are written in one transaction under the catalog version and category locks, each recorded once (`catalog.category.updated`, `catalog.category.moved`). A `PATCH` with only `parentCategoryId` is admitted under `catalog.category.moved` |
| Group and option restore | `archived → inactive`; nothing else to check (codes stayed unique, the 500 groups and the 50 options of a group count archived ones). An option of an archived group answers `422 · catalog.modifier_group_archived` whatever the transition. Whether an item can still be sold is judged by the till reads, as for any status change |
| Offer defaults | Deactivating or archiving a live group, option or variant removes it from every offer default (one `DELETE`, the count recorded); a restore never runs that removal, one shared rule (`retireOfferDefaults` with the status before and after) |
| Menu restore and unpublish | `restore` brings the archived menu back as draft with its structure; `unpublish` takes an active menu back to draft, where it may be emptied; `activate` keeps refusing `catalog.menu_empty` and `catalog.menu_nothing_available`; an archived menu stays read-only for `PATCH` and `PUT .../structure` (`catalog.menu_archived`) |
| Audit | Revision 112 (migration `0102`, expand-only) seeds `catalog.category.restored`, `catalog.modifier_group.restored`, `catalog.modifier_group.option_restored`, `catalog.menu.unpublished`, `catalog.menu.restored` and `catalog.item.variant_restored`, which a variant restore now records instead of `catalog.item.variant_updated`. `src/modules/catalog/application/catalog-audit-vocabulary.spec.ts` proves every action the catalog records is seeded |
| Statements | Category 12 (13 for a restore under a parent, 10 for a repeat); group and option 14 (13 for a reactivation or restore, 11 for a repeat); menu 10 for each transition (8 for a repeat): `test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts` |
| OpenAPI | Breaking: the twelve removed paths; the document moves to `9.0.0`. `LifecycleRoute` is gone; the four routes use `CatalogTransitionRoute` |
| Proof | The C9, C12 and M1 rows of [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) |

### API v7 PR 4 as built: one barcode set, one images route and a conditional bulk (migration `0103`, baseline revision 113)

Seven routes become three (`-4`, Catalog 50 to 46): the barcodes of a
variant are replaced as one set, the item list and the variant lists of an
item are replaced by one images request under one `If-Match`, and each entry
of the bulk images route may name the item version it replaces.

| Before | After |
|---|---|
| `POST /catalog/items/{itemId}/variants/{variantId}/barcodes`, `PATCH .../barcodes/{barcodeId}`, `POST .../barcodes/{barcodeId}/deactivation` and `.../reactivation` | `PUT /catalog/items/{itemId}/variants/{variantId}/barcodes` with `{ barcodes: [{ barcodeId?, barcode, symbology?, isPrimary?, suspended? }] }` |
| `PUT /catalog/items/{itemId}/images` with `{ images }` and `PUT /catalog/items/{itemId}/variants/{variantId}/images` with `{ images }` | `PUT /catalog/items/{itemId}/images` with `{ images, variants? }`, `variants` being `[{ variantId, images }]` |
| `PUT /catalog/item-images` with `{ items: [{ itemId, images }] }` | the same, each entry with an optional `ifMatch` |

```json
PUT /api/v1/catalog/items/0193…a1/variants/0193…a2/barcodes
If-Match: "<strong tag of the item, as GET /catalog/items/{itemId} answers it>"
{
  "barcodes": [
    { "barcodeId": "0193…b1", "barcode": "7750182002363", "symbology": "ean13", "isPrimary": true },
    { "barcode": "CAM-OX-M-AZUL", "symbology": "code128", "suspended": true }
  ]
}

200 OK
ETag: "<new strong tag of the item>"
{
  "data": {
    "variantId": "0193…a2",
    "barcodes": [
      { "barcodeId": "0193…b1", "barcode": "7750182002363", "symbology": "ean13", "isPrimary": true, "suspended": false, "status": "active" },
      { "barcodeId": "0193…b7", "barcode": "CAM-OX-M-AZUL", "symbology": "code128", "isPrimary": false, "suspended": true, "status": "inactive" }
    ]
  },
  "meta": {}
}
```

| Barcodes | As built |
|---|---|
| Ids | A barcode keeps its id when sent back; one sent without an id takes back the id of a barcode the variant has or had with the same GTIN (a live one before an archived one), so a retried save never duplicates; otherwise a new id. A value never changes under its id (`422 · catalog.barcode_value_fixed`); an id the variant never had answers `404` |
| Left out | Archived (status `archived`, migration `0103`): never deleted, never scans, its value is free and it no longer counts toward the 20; the item read and the answer leave it out; sending its value or id brings it back |
| `suspended` | `true` keeps the barcode in the set as `inactive` (it does not scan and its value is free); a suspended barcode cannot be primary (`422 · catalog.barcode_not_active`) |
| Precondition and tag | `If-Match` required on the item tag (`428`, `412`); no dedicated `GET`: `GET /catalog/items/{itemId}` already returns every variant with its barcodes and the same tag, which is also the tag the bundle groups, categories and images writes use, so one read serves every write of the item. A barcode tag of its own would let two editors of one item save barcodes and images at once without a `412`, but would cost a second read for every editor and a route; not worth it at 20 barcodes per variant |
| Locks | Catalog version row, item row, then the barcode rows of the variant (read once); values brought to life are checked against other variants, and the unique active value index stays the final arbiter (`409`) |
| Writes | Rows that stop being active first, then rows that become active, then the one primary, then the new rows: four statements at most whatever the number of barcodes |
| Gating | A set that only suspends, archives or drops the primary never asks the plan; adding, bringing back, making primary or changing a symbology does |
| Audit | One `catalog.item.barcodes_set` per change with `catalog.item.variant` and `catalog.item.variant.barcodes`, the live set before and after as a JSON list of `[id, value, symbology, primary, status]` sorted by id; the change check compares the same key, so an unchanged set records nothing |
| Refusals | Each points at its entry: `barcodes[i].barcode` (`catalog.barcode_invalid`, `catalog.barcode_value_fixed`, `catalog.barcode_taken`), `barcodes[i].barcodeId` (`catalog.not_found`), `barcodes[i].isPrimary` (`catalog.barcode_not_active`); a repeated GTIN or id, two primaries or 21 barcodes answer `400` at the schema. `catalog.variant_barcodes_full` is gone |
| Statements | 15 to add, suspend and archive at once, the same for 1 and 10 barcodes (`test/e2e/statement-budget.e2e-spec.ts`; 23 before the one-statement item read) |

Images:

```json
PUT /api/v1/catalog/items/0193…a1/images
If-Match: "<strong tag of the item, as GET /catalog/items/{itemId} answers it>"
{
  "images": [{ "assetId": "0193…f1" }],
  "variants": [
    { "variantId": "0193…a2", "images": [{ "assetId": "0193…f2" }, { "assetId": "0193…f3" }] },
    { "variantId": "0193…a3", "images": [] }
  ]
}

200 OK
ETag: "<new strong tag of the item>"
{ "data": { "itemId": "0193…a1", "images": [ … ], "variants": [ … ] }, "meta": {} }
```

| Item | As built |
|---|---|
| Lists | `images` is required and is the whole item list; `variants` is optional and holds at most 100 lists, each variant once. Every list takes at most 10 images, each once, the first is its primary and an empty list removes them |
| A variant not sent | Keeps its images. Chosen over "not sent means emptied" because most clients only manage the item list: with the replace-everything rule, a client unaware of variant images would wipe them on every save. The tag still protects against a lost update, since `If-Match` names the version of the whole item, variant lists included |
| Precondition | `If-Match` required (`428 · precondition.required`), judged on the item tag after the catalog version and item locks (`412 · foundation.precondition_failed` when stale); two concurrent saves with the same tag end in one `200` and one `412` |
| Refusals | A variant of another item or unknown `404 · catalog.not_found`; an archived variant `422 · catalog.variant_archived`, an archived item `422 · catalog.item_archived`; an asset that cannot be attached `422 · catalog.image_not_attachable` naming it; a repeated variant `400` at `variants[i].variantId` |
| Writes | One transaction: catalog version row, item row, then the item list (three statements) and every changed variant list together (three batched statements, `unnest` of variant, asset and position), variants in id order. New assets are checked once for all lists. Gated by the plan unless every changed list only removes images |
| Audit | Unchanged codes: one `catalog.item.images_set` when the item list changed and one `catalog.item.variant_images_set` per changed variant list; sending the lists the item has answers `200` with the same tag and records nothing |
| Bulk `ifMatch` | Optional per entry, one quoted strong ETag (a wildcard or a weak tag is a `400` at `items[i].ifMatch`). After the items are locked, an unknown item is refused first (`404`), then every stale tag (`412 · foundation.precondition_failed`, `meta.errors` naming `items.<itemId>` with `precondition_failed`, even when its list would not change), then an archived item (`422`); all or nothing as before. An entry without it stays unconditional, as an import needs; the tags cost one extra read of those items only |
| Body | The largest images request (10 item images and 100 variant lists of 10) is 57,935 bytes and the largest bulk request with the longest `ifMatch` on every entry 71,611 bytes: both stay on the 100 KB default |
| Statements | Item list only: 17 (25 before the one-statement item read of API v7 PR 6); variant lists: 15 plus one audit per changed list, whatever the number of variants (`test/e2e/statement-budget.e2e-spec.ts`); the bulk route is unchanged without `ifMatch` |
| OpenAPI | Breaking: the removed variant images path and the four per-barcode operations; the document moves to `10.0.0` |
| Proof | The C11 and C26 rows of [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) |

### API v7 PR 5 as built: one modifier group set and one offers route (no migration, baseline revision 113)

Five routes become two (`-3`, Catalog 46 to 43): a modifier group and its
whole option set are replaced in one `PUT`, and the item offers and the
overrides of its variants travel in one offers request under one `If-Match`.
No audit action is added: the set records `catalog.modifier_group.updated`
and the offers keep `catalog.item.modifier_groups_set`.

| Before | After |
|---|---|
| `PATCH /catalog/modifier-groups/{modifierGroupId}` | `PUT /catalog/modifier-groups/{modifierGroupId}` |
| `POST /catalog/modifier-groups/{modifierGroupId}/options` | the same `PUT` (an option without `optionId` is new) |
| `PATCH /catalog/modifier-groups/{modifierGroupId}/options/{optionId}` | the same `PUT` (an option sent back with its id) |
| `PUT /catalog/items/{itemId}/modifier-groups` with `{ modifierGroups }` | `PUT /catalog/items/{itemId}/modifier-groups` with `{ offers, variantOverrides? }` |
| `PUT /catalog/items/{itemId}/variants/{variantId}/modifier-groups` | `variantOverrides` of the same `PUT` |

`POST /catalog/modifier-groups`, both `GET`s of the groups, `GET .../offers`
and both transitions routes stay: taking one option off sale for the day
must not require sending the whole set.

Modifier group set:

```json
PUT /api/v1/catalog/modifier-groups/0193…d1
If-Match: "<strong tag of the group, as GET /catalog/modifier-groups/{modifierGroupId} answers it>"
{
  "code": "termino-de-la-carne",
  "name": "Término de la carne",
  "description": "Cómo prefiere la carne.",
  "options": [
    { "optionId": "0193…d2", "code": "tres-cuartos", "name": "Tres cuartos" },
    { "name": "Tocino", "kind": "catalog_variant", "variantId": "0193…a9", "maxQuantity": 2 },
    { "optionId": "0193…d3", "name": "Bien cocida", "suspended": true }
  ]
}

200 OK
ETag: "<new strong tag of the group>"
{
  "data": {
    "modifierGroupId": "0193…d1", "code": "termino-de-la-carne", "name": "Término de la carne",
    "description": "Cómo prefiere la carne.", "status": "active",
    "options": [
      { "optionId": "0193…d2", "code": "tres-cuartos", "name": "Tres cuartos", "description": null, "kind": "instruction", "variantId": null, "maxQuantity": 1, "sortOrder": 0, "status": "active" },
      { "optionId": "0193…e1", "code": "tocino", "name": "Tocino", "description": null, "kind": "catalog_variant", "variantId": "0193…a9", "maxQuantity": 2, "sortOrder": 1, "status": "active" },
      { "optionId": "0193…d3", "code": "bien-cocida", "name": "Bien cocida", "description": null, "kind": "instruction", "variantId": null, "maxQuantity": 1, "sortOrder": 2, "status": "inactive" },
      { "optionId": "0193…d4", "code": "jugoso", "name": "Jugoso", "description": null, "kind": "instruction", "variantId": null, "maxQuantity": 1, "sortOrder": 3, "status": "archived" }
    ]
  },
  "meta": {}
}
```

A refusal points at the option:

```json
409 Conflict
{ "code": "catalog.code_taken", "status": 409, "meta": { "errors": [
  { "location": "body", "field": "options[1].code", "code": "catalog.code_taken",
    "message": "Another option of this group, or one it had before, holds this code ignoring case; choose another code, or send the id of the option that holds it." }
] } }
```

| Set | As built |
|---|---|
| Body | `code` and `name` required, `description` null or absent leaves none; `options` required, at most 50, the order sent is the till order; an option takes `optionId?`, `code?`, `name`, `description?`, `kind` (default `instruction`), `variantId` (only for `catalog_variant`), `maxQuantity` (default 1) and `suspended` (default false). A repeated `optionId` or code (ignoring case) is a `400` at the repetition |
| Ids | An option keeps its id when sent back; one sent without an id but with the code of an option the group has or had, ignoring case, takes that id back (so a retried save never duplicates); otherwise a new id and, without a code, one derived from its name that no option of the group, archived ones included, holds |
| Left out | Archived, never deleted: past sales, offers and Pricing rows that name it stay true; it keeps its code and still counts among the 50 (`422 · catalog.modifier_group_full` when stored plus new options pass 50); sending its id or code again brings it back with the same id, `active` or `inactive` as `suspended` says |
| `suspended` | `true` makes the option `inactive`, the meaning of `deactivate` on the transitions route |
| Target | The kind and the variant of a kept option never change (`422 · catalog.modifier_option_target_fixed` at `options[i].kind` or `.variantId`); the variant of a new option is judged once, share-locked in id order: unknown or of another organization `404 · catalog.not_found`, not active `422 · catalog.variant_not_active`, both at `options[i].variantId` |
| Codes | A code held by another option of the group, archived ones included, is `409 · catalog.code_taken` at `options[i].code`; a group code taken in the catalog `409` from the unique index. Because a request never reuses a stored code of another option, the option writes cannot collide mid-statement |
| Precondition and tag | `If-Match` required on the group tag (`428`, `412`); the answer is the whole group as `GET` reads it with its new tag; two sets sent at the same version end in one `200` and one `412` |
| Locks | Catalog version row, group row `FOR UPDATE`, then the variants of new options `FOR SHARE` in id order |
| Writes | The profile when it changed, one batched `UPDATE` of the changed options (`unnest`, scoped to the group), one batched `INSERT` of the new ones, then one `DELETE` of the offer defaults of the options that stop being sellable (active to inactive or archived, inactive to archived), the count recorded as `catalog.modifier_group.offer_defaults_removed`. Whether a required group can still reach its minimum is judged by the till reads, as for any option status change |
| Gating | A set that only suspends or archives options, keeping the order of the rest and every other value, never asks the plan; a rename, a new option, an edit, a reorder or bringing an option back does |
| Audit | One `catalog.modifier_group.updated` per change: the changed `code`, `name` and `description` and `catalog.modifier_group.option_set`, the live options in their order as `[id, code, name, description, kind, variant, maxQuantity, status]` before and after; the change check compares that same key, so sending the group as it is answers `200` and records nothing |
| Archived group | `422 · catalog.modifier_group_archived` whatever the set |
| Body ceiling | 512 KiB (`2 × 256 KiB`), the smallest step that holds the largest documented body (50 options with 2,000-character descriptions) |
| Statements | 18 to rename, add, suspend, edit and archive at once, the same for 1 and 10 options (`test/e2e/statement-budget.e2e-spec.ts`) |

Offers:

```json
PUT /api/v1/catalog/items/0193…a1/modifier-groups
If-Match: "<strong tag of the item, as GET /catalog/items/{itemId} answers it>"
{
  "offers": [
    { "modifierGroupId": "0193…d1", "minSelections": 1, "maxSelections": 1, "defaultOptionIds": ["0193…d2"] },
    { "modifierGroupId": "0193…c1", "minSelections": 0, "maxSelections": 3, "suspended": true }
  ],
  "variantOverrides": [
    { "variantId": "0193…a2", "offers": [{ "modifierGroupId": "0193…c1", "minSelections": 0, "maxSelections": 6 }] },
    { "variantId": "0193…a3", "offers": [{ "modifierGroupId": "0193…d1", "minSelections": 0, "maxSelections": 1, "optedOut": true }] },
    { "variantId": "0193…a4", "offers": [] }
  ]
}

200 OK
ETag: "<new strong tag of the item>"
{ "data": { "itemId": "0193…a1", "modifierGroups": [ … ], "variants": [ { "variantId": "0193…a2", "modifierGroupOverrides": [ … ] }, … ] }, "meta": {} }
```

| Offers | As built |
|---|---|
| Lists | `offers` is required and is the whole item list (at most 20); `variantOverrides` is optional and holds at most 10 variant lists per request, each variant once, each list at most 20 groups. `suspended` belongs to an item offer and `optedOut` to a variant override (`400` otherwise) |
| A variant not sent | Keeps its overrides, and so does every variant when `variantOverrides` is absent: a client that manages only the item list never wipes the overrides; a variant sent with an empty list clears them and follows the item again. Chosen as for the images route |
| Rules | Unchanged: offer ids kept for ever (K9b), retire instead of delete, `suspended`, `optedOut`, min, max and defaults, the effective-offer rules, the activation judgement of a resumed or opted-in group, the per-variant rules of K9g |
| Refusals | Each points at its field: `offers[i].modifierGroupId` (`404 · catalog.not_found`, `422 · catalog.modifier_group_not_active`), `.minSelections` (`422 · catalog.selection_rule_invalid`), `.defaultOptionIds` (`422 · catalog.default_options_invalid`), under `variantOverrides[j].offers[i]` for a variant list; `variantOverrides[j].variantId` for a variant of another item or unknown (`404`) or archived (`422 · catalog.variant_archived`); an archived item `422 · catalog.item_archived` |
| Precondition and tag | `If-Match` required on the item tag, which covers every variant list (`428`, `412`); the answer is the whole item with its new tag |
| Locks | Catalog version row, item row `FOR UPDATE`, then the groups of every changed list `FOR SHARE` once, in id order; the variant writes are serialised by the item lock, as every variant write of the catalog |
| Writes | Only the lists that changed, each judged first and written after: all changed lists or none |
| Gating | Per list as before (only removing, suspending or opting out never needs the plan); the plan is asked once when any changed list needs it |
| Audit | One `catalog.item.modifier_groups_set` per changed list, as before |
| Body ceiling | The same 512 KiB parser as the group set: the largest documented body (20 offers with 50 defaults each, plus 10 such variant lists) fits it; one parser only, mounted behind the bearer credential like the others |
| Statements | 21 for the item list and one variant list (29 before the one-statement item read of API v7 PR 6), 5 more per further changed variant list (`test/e2e/statement-budget.e2e-spec.ts`) |
| OpenAPI | Breaking: the removed `PATCH` of the group, the option add and edit paths, the variant offers path and the `modifierGroups` body property; the document moves to `11.0.0` |
| Proof | The C12 and C13 rows of [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) |

### API v7 PR 6: the item read as built (no migration, baseline revision 113)

| Item read | As built |
|---|---|
| One statement | `READ_ITEM`, `LOCK_ITEM` (`FOR UPDATE OF i`) and `READ_ITEMS` (`ANY($1::uuid[])`) in `infrastructure/items/item-read.queries.ts` answer one row per item that nests, as `json_agg` in keyed scalar subqueries, the variants (with their live barcodes, their overrides and their images), the categories, the item offers with the options and defaults of each group, and the item images. The old read sent five statements (items joined with variants, categories, offers, options, barcodes) and joined them in Node, filtering every row set per item and handing every barcode row to every item |
| Images | The item images are aggregated once per item and the images of a variant once per variant, each through a keyed index of `item_assets` (the planner picks `item_assets_item_asset_uq` and `item_assets_variant_asset_uq`); before, the item images were aggregated again on every variant row |
| Representation | Byte for byte the same: `item-read.golden.integration-spec.ts` compares a rich item key by key with `test/support/rich-item.golden.json`, written from the five-statement read before the change, and read, lock and read-many answer the same entity tag |
| Plan stability | Every child is reached through its parent by key, no `EXISTS`, no `OFFSET`; a category, a modifier group and a default option are looked up by primary key in a `LATERAL ... LIMIT 1` per link, so the planner cannot reorder the join and scan the organization's categories or groups for every item (the first version joined them, and with statistics taken while those tables were nearly empty it read the 500 categories of the organization for every link). The spec imports the same catalog into a neighbour before `ANALYZE` and into the measured organization after it: 2,001 items (two variants, two offers on 2,000 groups, a barcode per variant, images and 500 categories), a 40-variant item and an item with the 500 variants allowed, each overriding the same 20 groups. Measured: the 40-variant item 343 rows and 705 buffers, 100 items 2,300 rows and 6,212 buffers, the 500-variant item (locked) 20,581 rows and 24,713 buffers; no sequential scan, no looped scan wider than 50 rows, and no single lookup reads more rows than the item owns. Bounds: 1,000 rows and 2,000 buffers, 4,000 and 10,000, 40,000 and 50,000 (`item-read.plan.integration-spec.ts`) |
| Shared groups | The facts and the options of every group the item offers are built once per item (`SELECT DISTINCT` of the offered groups, one `jsonb` object keyed by group id) and each offer takes its group from it, instead of rebuilding the options once per offer: the 500-variant item with 20 overrides each went from 50,501 rows, 88,755 buffers and about 110 ms to 20,581 rows, 24,713 buffers and about 57 ms of execution (local, superuser, no RLS) |
| Writes | Unchanged bodies; every item read is now one statement plus one `describe` when there are images. Writes that read the item twice, under the lock and again for the answer, save eight statements: a variant transition, `PUT .../categories`, the images, the barcode set and the offers. Writes that read it once save four: an item transition (it answers `{ itemId, status }` with the tag of the locked item after the step, computed in Node, so its tag costs no statement beyond the lock), the create and the `PATCH` |
| Statements | `GET` item 7 (11 before), the same for `304`; create 14 (18); `PATCH` 10 (14); `PUT .../categories` 13 (21); item images 17 (25); barcode set 15 (23); offers 21 (29); item transition 10, 8 for a repeat (14, 12); variant transition 12, 11 for a reactivation or restore, 8 for a repeat (20, 19, 12) (`test/e2e/statement-budget.e2e-spec.ts`) |
| Per-item version: not built | Decided against with the numbers above. A `304` already costs the same 7 statements as a `200`; a version column would replace the one read with a one-row probe (about 1 ms of execution and 561 to 705 buffers for 40 variants, 4 buffers for the probe) but not remove a statement. Its costs: the representation holds facts other tables and modules own, so the version would either fan out (an edit of a group offered by 1,500 items updates 1,500 item rows under the catalog version lock) or the tag would have to combine the item version with the groups it offers; and the delivery status and URLs of the images belong to Assets, whose changes (an image finishing processing, an archive) never touch a catalog row, so a version-only tag would answer a stale `304`. It would also need migration `0104`, canonical files, contract tests and an upgrade proof. The content hash stays the tag |
| OpenAPI | Unchanged by the read. The same PR removes `categoriesTruncated` from `GET /catalog/workspace` (a catalog holds at most 500 categories, the workspace answers up to 500, so it could never be true), bounds `quantityPerSelection` of a bundle choice to 21 characters and corrects three descriptions; the document moves to `12.0.0` |
| Proof | The rows "The item read is one statement", "The item representation is byte for byte the one before the one-statement read" and "The item read stays keyed under stale statistics" of [`CATALOG_CASE_MATRIX.md`](CATALOG_CASE_MATRIX.md) |

### API v7 PR 7 as built: one listing-changes route, a richer item list and a category tree version (migration `0104`, baseline revision 114)

Two routes become one (`-1`, Catalog 43 to 42). The item list gains what an
admin grid needs without one detail call per row, and the category tree gets
its own version, so item writes stop invalidating it.

| Before | After |
|---|---|
| `POST /catalog/business-units/{businessUnitId}/assortment/listings` with `{ itemIds }` | `POST /catalog/business-units/{businessUnitId}/assortment/listing-changes` with `{ list }` |
| `POST /catalog/business-units/{businessUnitId}/assortment/delistings` with `{ itemIds }` | the same route with `{ delist }`, or both lists in one change |
| `GET /catalog/items?status=active` (one status) | `status=active,inactive` (one or several, comma separated) |
| `GET /catalog/items?categoryId=` (that category only) | `categoryId=&includeDescendants=true` adds the two levels below it |
| no image filter | `hasImage=true` or `hasImage=false` |
| a row without image | every row carries `primaryImage`: `{ assetId, thumbnailUrl }` or `null` |
| `GET /catalog/categories` tagged with the organization-wide version | tagged with `catalog_versions.categories_version` |

Listing changes:

```json
POST /api/v1/catalog/business-units/0193…09/assortment/listing-changes
If-Match: "<optional: the tag of GET .../assortment>"
{
  "list": ["0193…a1", "0193…a4"],
  "delist": ["0193…a3"]
}
```

| Listing changes | As built |
|---|---|
| Body | `list` and `delist`, both optional, up to 200 ids each, each once, at least one id in all; an id in both answers `400` with `meta.errors[].field` such as `delist[0]`; an unknown field such as `itemIds` answers `400` |
| Rules kept | Mode `all`: a listing lifts an exclusion, a delisting writes one; mode `selected`: a listing writes an inclusion, a delisting withdraws it; mode `none`: `422 catalog.assortment_disabled`. An archived item is refused only in `list` (`422 catalog.item_archived`); an unknown item or one of another organization answers `404 catalog.not_found` for the whole change; nothing is written then |
| Plan and permission | `catalog.assortment.manage` anchored at the unit, as before. The plan (capability `catalog.items`) gates the change only when `list` names an item; a change that only delists never needs it, so withdrawing from sale always works |
| Audit | One `catalog.assortment.items_listed` record naming the items that started selling and one `catalog.assortment.items_delisted` record naming the ones that stopped, each only when its direction changed something; a change that changes nothing records nothing. A refusal is recorded under `items_listed` when `list` names an item and under `items_delisted` otherwise |
| Statements and locks | One permission step, the catalog version row locked first, one read of the stored mode, one share of every named item in id order, then per changed direction one insert and one update at most; the answer is the assortment as `GET .../assortment` reads it |
| If-Match | Optional, as the owner decided: the assortment is a mode and a membership set, not a whole-collection replace |

| Item list | As built |
|---|---|
| Several statuses | Each status is its own ordered branch (`ORDER BY name_search_key, id LIMIT 51` on `catalog_items_name_search_idx`) merged with `UNION ALL` and limited again, so every branch stops at the page; a single status keeps the one scan it always had. A text prefix is judged per branch |
| Category tree | `includeDescendants=true` keeps the items classified in the category or in the categories one or two levels below it (`categories_parent_idx`, no recursion: a tree is at most three deep), whatever their status. Both category filters now read the classified item ids once per statement (`id = ANY (ARRAY(...))`, an InitPlan) instead of once per scanned item |
| Image filter | `hasImage` is one keyed probe per scanned item on `item_assets_one_item_primary_uq` (the item primary image; an image of a variant does not count) |
| `primaryImage` | Read in the same statement through the same unique index; the page then asks Assets once for the thumbnail of every distinct primary image (`AssetDeliveryContract.describe`, rendition `thumbnail`). `thumbnailUrl` is `null` while the image is processing, when it failed or when the media host is not configured. The key is always present: `null` for an item without a primary image, as `primaryCategory` already does, so a row without image differs from before only by `"primaryImage": null` |
| Workspace | Its first page is read by the same statement and enriched by the same call, so it keeps matching the first page of the list key by key (`catalog-item-list-filters.e2e-spec.ts`) |
| Cursor | Bound to the route, the organization and every filter: the statuses (in lifecycle order, so `inactive,active` and `active,inactive` share cursors), `q`, `categoryId`, `includeDescendants` when true, `hasImage` when sent, `businessUnitId` and `listed`. A cursor sent with another filter set answers `400 collection.cursor_incompatible`; the workspace `endCursor` is still accepted by `GET /catalog/items` with no other filter |
| Plan stability | Right after importing 2,001 items (two variants each, a third inactive, a primary image on every other item, every fourth item in a three-level tree, the rest in 100 categories) before the statistics know it, a first page of 50 reads: three statuses 332 rows and about 1,060 buffers; the category tree 1,142 rows and about 1,130 buffers; one exact category 833 rows and about 1,050 buffers; `hasImage=true` 458 rows and about 1,300 buffers, `false` 410 and about 1,240; two statuses with the tree and `hasImage=true` 2,080 rows and about 1,630 buffers; no sequential scan and no wide looped scan; the spec bounds each at 2,500 rows and 2,500 buffers (`item-list.plan.integration-spec.ts`). Before the change an exact category read 88,664 rows (the classified items were scanned again for every item); the tree filter written the same way read 88,880 |
| Plan stability, CI correction | The CI of the promotion found the primary category of each row read by a looped `Seq Scan on categories` (102,051 loops) when the categories table was nearly empty at the last `ANALYZE`, and a text prefix whose SKU branch ran once per scanned item (4,002 rows x 718 loops locally). The row summary now reads the primary category id of each row through `item_categories_one_primary_uq` (`LIMIT 1`, never flattened) and every category name of the page once (`category_names`, a `jsonb_object_agg` over `id = ANY (ARRAY(...))`), the prefix and the second tree level are evaluated once per statement (`id = ANY (ARRAY(...))`), and the plan spec asserts that no scan of `categories` repeats per row. A second correction after the full integration run (where `together` read 4,667 rows): the items of a category and the items matching a text prefix are now one candidate set read once per statement (`candidates`, the two intersected when both are sent) instead of once per status branch, each status branch keeps its ordered scan and tests membership with a filter that is never an index condition (`coalesce(id = ANY (...), false)`), and the SKU branch of the prefix states `sku_search_key IS NOT NULL` so its partial index applies. The ordered scan stays (owner decision): a bad plan of an admin list costs a few thousand rows, not a catastrophe. The plan spec therefore checks invariants that hold in the good and in the bad plan: no sequential scan of `items`, `item_variants`, `item_assets` or `item_categories`, no looped wide scan, no scan of `categories` repeated per row, three statuses at most three single-status pages, and ceilings of 4 rows and 4 buffers per imported item for the filters and 8 for a text prefix. Good plan: `together` about 2,480 rows and 2,230 buffers, a one-letter prefix 12,710 rows; bad plan forced with near-empty statistics: `together` 4,260 rows and 3,350 buffers, an image filter 5,330 buffers; the worst forced with `enable_nestloop = off`, 6,210 rows. Its fixture now holds 497 categories besides the tree, near the 500 a catalog may hold; with it every filter reads at most about 2,820 rows and 1,550 buffers (bound 3,000 rows and 2,500 buffers) and a prefix about two pages' worth per status |
| Prefix across statuses | A prefix that matches few names (`bulk 19`) still reads every item of a status under stale statistics (3,666 rows for one status, a cost the prefix filter already had); two statuses read 5,334, never more than two single-status pages, which the spec asserts |
| Statements | 7 for a page whatever the filters (several statuses, the tree, the image filter, as before for one status or `categoryId`), 8 with `listed` (the unit status, as before), and one more when the page holds primary images (the Assets call): 8, and the workspace 12 without images and 13 with them (`catalog-reads-statement-budget.e2e-spec.ts`) |

Item list request and row:

```json
GET /api/v1/catalog/items?status=active,inactive&categoryId=0193…c1&includeDescendants=true&hasImage=true&limit=50
{
  "data": [
    {
      "itemId": "0193…a1",
      "code": "hamburguesa-clasica",
      "name": "Hamburguesa clásica",
      "status": "active",
      "defaultVariant": { "variantId": "0193…a2", "sku": "HB-001", "salesUnitCode": "unit" },
      "variantCount": 1,
      "primaryCategory": { "categoryId": "0193…c1", "name": "Platos de fondo" },
      "primaryImage": {
        "assetId": "0193…e1",
        "thumbnailUrl": "https://media.ninaku.io/a/k3j7m2q9x4v8b6n5c1d0f7g2h4/p1/thumbnail.webp"
      }
    }
  ],
  "meta": { "pageInfo": { "hasNextPage": true, "hasPreviousPage": false, "startCursor": "…", "endCursor": "…" } }
}
```

| Category tree version | As built |
|---|---|
| Column | `catalog.catalog_versions.categories_version bigint NOT NULL DEFAULT 1`, `catalog_versions_categories_version_chk` (> 0) |
| Backfill and tag format | Every existing row takes its current `version`. The tag also changes form, from `organization:version:categories-<status or all>` to `organization:category-tree:<tree version>:<status or all>` (`category-tree-entity-tag.ts`), so a tag issued from the organization-wide version, before the upgrade or by an old instance during a rolling deploy, can never equal a new one even when the numbers coincide: such a client gets one `200` and the new tag |
| Triggers | The three statement-level triggers of `catalog.categories` now run `catalog.bump_category_tree_version()`: one `UPDATE` of the version row that moves `version` as before and `categories_version` when the statement inserts or deletes categories or changes the parent, code, name, description, sort order or status of one (the update trigger compares `OLD TABLE` and `NEW TABLE`); an update that leaves those columns as they were moves only `version`. No other table moves it, so item, variant, classification, image and assortment writes keep the tree tag |
| Guard | `catalog.guard_catalog_version_step()` still demands `version + 1` and lets `categories_version` stay or move by one; the runtime receives `UPDATE (categories_version)`, which the trigger needs, and cannot forge it (contract `catalog-category-tree-version-contracts.ts`) |
| Effective visibility | Derived from ancestors, which are categories, so every change that can alter `isEffectivelyActive` or `blockedByAncestorId` moves the tree version. The tree body holds categories only (no item counts), so item writes cannot change it |
| Not changed | The till tag, the assortment tag and every other tag keep the organization-wide `version`, which category writes still move |
| Upgrade proof | A revision-113 database built from `origin/staging`, with a catalog and two categories seeded in a superuser `DO` block under a tenant context, upgraded by `db:prepare`: its `pg_dump --schema-only` (privileges included) equals a fresh revision-114 install apart from the `\restrict` keys, the seeded rows survive and `categories_version` equals `version` (6) |

OpenAPI moves to `13.0.0`. Breaking (`npm run openapi:compat` against
`origin/staging`): the two former assortment routes are removed (and PR 6's
`categoriesTruncated`). Additive: `listing-changes`, the `includeDescendants`
and `hasImage` parameters, and `primaryImage` on every row of the list and of
the workspace page; `status` widens from one value to a comma-separated
text, which the check counts as compatible.

### M2a as built: which menus an outlet shows, and in what order (migration `0105`, baseline revision 115)

Two operations are added (Catalog 42 to 44). Gap G1 is closed.

#### C29 — Choose which menus an outlet shows, and in what order

**Owner decisions.** The menus assigned to an outlet only organize what its
till shows, in that order; they never limit what it sells. The assortment of
the business unit that operates the outlet and the sellable rule stay the only
rule of what sells, and `CatalogSellableContract.admit` does not look at menus.
**Actor:** the read needs `catalog.items.view` or `catalog.assortment.manage`
**at the outlet** (`PermissionCheckContract` with `outletId`, which a broader
scope covering the outlet also satisfies); the write needs
`catalog.assortment.manage` at the outlet. `catalog.items.manage` alone is not
enough, as for the assortment.
**Plan:** copied from the sibling assortment writes, which are gated for the
same permission: any list but the empty one is gated by `catalog.items`
(`platform.authorize_operation` with the outlet, so an outlet that is not
operating answers `422 · authorization.outlet_not_operational`, mapped by the
catalog since this slice); the empty list is never gated, so taking every menu
off an outlet always works, like a delisting.
**Routes:** `GET /catalog/outlets/{outletId}/menus` and
`PUT /catalog/outlets/{outletId}/menus` with `{ "menuIds": [...] }`.

```json
PUT /api/v1/catalog/outlets/0193…e1/menus
If-Match: "<the ETag of GET /catalog/outlets/0193…e1/menus>"
{
  "menuIds": ["01930000-0000-7000-8000-0000000000d0", "01930000-0000-7000-8000-0000000000d9"]
}
```

```json
200 OK
ETag: "<a new strong tag>"
{
  "data": {
    "outletId": "01930000-0000-7000-8000-0000000000e1",
    "menus": [
      { "menuId": "01930000-0000-7000-8000-0000000000d0", "code": "almuerzo", "name": "Menú del almuerzo", "status": "active" },
      { "menuId": "01930000-0000-7000-8000-0000000000d9", "code": "bebidas", "name": "Bebidas", "status": "active" }
    ]
  }
}
```

| C29 | As built |
|---|---|
| Answer | `{ outletId, menus: [{ menuId, code, name, status }] }` in the order the till shows them; an outlet nobody assigned menus to answers `menus: []`. The write answers the same body as the read, with the new tag |
| Body | `menuIds`, required, 0 to 10 menu ids (`OUTLET_MENU_LIMIT`, one per position the table allows), each once; an eleventh answers `400` at `menuIds`, a repetition `400` at `menuIds[i]`, any other field `400` |
| Judgement | Only a menu that starts being shown is judged: unknown or of another organization `404 · catalog.not_found`, archived `422 · catalog.menu_archived`, each with `meta.errors[].field` such as `menuIds[1]` and a message that says how to fix it; an unknown menu is answered before an archived one. A draft menu may be assigned. A menu the outlet already shows keeps its place even if it was archived since, and stays listed with its status |
| No-op | The same ids in the same order answer `200` with nothing written, no record and the catalog version unchanged; a new order of the same menus is a change |
| ETag | A strong hash of the answer (`contentEntityTag`), so renaming, archiving or restoring a shown menu changes it; `If-None-Match` answers `304` with no body; `Cache-Control: private, no-cache`. The write uses `@RequiresIfMatch()`: missing, blank or `*` answers `428 · precondition.required`, a stale tag `412 · foundation.precondition_failed`, judged after the version lock and the read |
| Unknown outlet | Every caller refused at the outlet gets the same plain `403`; only a caller holding the permission organization-wide learns `404 · organization.structure_not_found`, through the published `organization.outlet_operating_context`, also when the gated decision answered `outlet_not_operational` for an outlet that does not exist |
| Locks and writes | The catalog version row `FOR UPDATE` first (menu transitions take it first too, so archiving or unpublishing a menu cannot race the assignment), then the stored list, then the new menus only, then one `DELETE` of the outlet's rows (when it had any) and one `INSERT ... unnest(...) WITH ORDINALITY` (when the list is not empty). The rows are links, deleted and inserted again; menus, sections and entries are never deleted. Each statement moves the catalog version once, so the tag of `GET /catalog/outlets/{outletId}/sellable-items` changes |
| Audit | One `catalog.outlet.menus_set` record (resource kind `organization.outlet`, the outlet id) per real change, with `catalog.outlet.menus` before and after as the comma-separated ids (`null` for none); refusals of the permission, the plan or the outlet are recorded as denied, like the sibling routes |
| Statements | `GET` 7 and `304` 7 (one permission, one read that also answers whether the outlet exists); `PUT` 15 whatever the number of menus, 14 without a stored list to delete or without a new menu to judge, 13 to empty it, 10 for the same list (`catalog-outlet-menus-statement-budget.e2e-spec.ts`); the 2 added statements are the lock of the outlet tree and the second permission decision of *Writes and reassignments of the outlet* |

**Writes and reassignments of the outlet.** `PUT /catalog/outlets/{outletId}/menus` and `PUT /catalog/outlets/{outletId}/hidden-items` decide `catalog.assortment.manage` at the outlet, and the business unit that operates the outlet is part of that decision, so a reassignment of the outlet that commits between the decision and the write must not let a caller anchored only at the old unit change the assortment of the new one. One gate, `admitOutletCommand` (`application/outlets/outlet-assortment-gate.ts`), serves both routes and any later outlet write: it decides the permission (a refused caller is audited and returns without taking any lock, so a caller without the permission can never block a reassignment), takes the shared outlet-tree advisory lock of the organization (`pg_advisory_xact_lock_shared` on the key `organization.outlet-tree:<organization id>` that Organization takes exclusively in every reassignment, reparent and update of an outlet), and decides again, in a new statement, so under READ COMMITTED the second decision sees every reassignment that committed before the lock was granted, while a later one waits for the write to commit. The lock order is the outlet tree (shared), then the catalog version; Organization takes its structure version, then the tree (exclusive), then the outlet row, and never the catalog version, and no other Catalog command takes an outlet lock, so the orders cannot form a cycle. A caller refused by the second decision gets the same `403 catalog.action_forbidden` and the denial audit record of the transaction; a caller that waits behind a reassignment longer than `DATABASE_LOCK_TIMEOUT_MS` gets `503 service.unavailable` and nothing is written. The two `GET`s read in one read-only snapshot like the till reads. Responses for authorized callers do not change |
| Table | `catalog.outlet_menus(organization_id, outlet_id, menu_id, position smallint, created_at)`, primary key `(organization_id, outlet_id, position)`, unique `(organization_id, outlet_id, menu_id)`, `position` 0 to 9, composite foreign keys to the outlet and to the menu of the same organization, index `(organization_id, menu_id)`; statement-level version triggers, the organization reassignment guard, RLS select, insert and delete; the runtime holds `SELECT`, `INSERT`, `DELETE`. Contract: `database/tests/contracts/catalog-outlet-menus-contracts.ts` |
| Upgrade proof | A revision-114 database built from `origin/staging`, with an organization, an outlet and two menus seeded in a superuser `DO` block under a tenant context, upgraded by `db:prepare`: its `pg_dump --schema-only` (privileges included) equals a fresh revision-115 install apart from the `\restrict` keys, the seeded rows survive and the runtime writes an assignment that moves the version |
| Front end | Read the list, reorder or edit it on screen, send the whole list with the tag read; refresh the tag after any write (the write answers it). An archived menu in the list is shown as archived; it can be kept or taken out, never added again until it is restored |

OpenAPI moves to `13.1.0`: `npm run openapi:compat` against `origin/staging`
finds no breaking change and one additive one, the path
`/api/v1/catalog/outlets/{outletId}/menus`.

### M2b as built: the till groups what an outlet sells by its menus (no migration, baseline revision 115)

No operation is added (Catalog stays at 44). C17 gains one top-level key.

**Owner decisions.** Menus only organize the till screen; they never limit
what sells. An item outside every menu still sells through the search (C19)
and the scan (C18); an outlet without menus answers `menus: []` and the till
keeps grouping by category. The key is only on the snapshot the till loads at
start, `GET /catalog/outlets/{outletId}/sellable-items`, never on the search
or the scan.

```json
GET /api/v1/catalog/outlets/01930000-0000-7000-8000-0000000000e1/sellable-items
200 OK
{
  "data": {
    "businessUnitId": "01930000-0000-7000-8000-0000000000b1",
    "categories": [ ... ],
    "items": [ ... ],
    "modifierGroups": [ ... ],
    "menus": [
      {
        "menuId": "01930000-0000-7000-8000-0000000000d0",
        "name": "Menú del almuerzo",
        "sections": [
          {
            "sectionId": "01930000-0000-7000-8000-0000000000d1",
            "name": "Entradas",
            "entries": [{ "itemId": "01930000-0000-7000-8000-0000000000f3" }],
            "subsections": [
              {
                "sectionId": "01930000-0000-7000-8000-0000000000d2",
                "name": "Frías",
                "entries": [{ "itemId": "01930000-0000-7000-8000-0000000000f1" }]
              }
            ]
          },
          {
            "sectionId": "01930000-0000-7000-8000-0000000000d3",
            "name": "Principales",
            "entries": [
              {
                "itemId": "01930000-0000-7000-8000-0000000000f5",
                "variantIds": ["01930000-0000-7000-8000-0000000000f6", "01930000-0000-7000-8000-0000000000f7"]
              }
            ],
            "subsections": []
          }
        ]
      }
    ]
  },
  "meta": { "catalogVersion": 42, "truncated": false, "omittedItems": 0, "media": { "tileUrlTemplate": "https://media.ninaku.io/a/{imageRef}/tile.webp" } }
}
```

| C17 menus | As built |
|---|---|
| Which menus | The menus assigned to the outlet (C29) whose status is `active` and whose window is open now (C31; a menu without a window is always open), in the assignment order (`outlet_menus.position`); a draft or archived menu is not shown, even while assigned |
| Shape | `menus: [{ menuId, name, sections: [{ sectionId, name, entries, subsections: [{ sectionId, name, entries }] }] }]`, the M1 names; an entry is `{ itemId }`, or `{ itemId, variantIds }` when its `variantMode` is `selected`. Nothing about the item is repeated: the till reads it from `items` by `itemId` |
| Order | Sections, subsections and entries in their M1 order; `variantIds` in the order of the selection |
| What is left out | A suspended or retired section, with its subsections; a suspended or retired entry; an entry whose item is not in the sellable set of the same answer (inactive, archived, delisted at the unit, without an active configurable variant, or a combo that cannot be composed); a selected variant that is inactive, archived or not configurable, and a selected entry left with none; then a section left with no entries and no non-empty subsection, and a menu left with no section |
| Same rule as the domain | The SQL answers what `isAvailableEntry` and `isActivePart` (`domain/menus`, `domain/shared`) decide with the item status read as "sold by this answer" and the variant statuses as "among the sold variants"; an integration spec runs both on the same fixtures |
| Truncated list | Entries are kept against the whole sellable set, not the 2,000 answered items: with `meta.truncated: true` an `itemId` may be outside `items`, and the till resolves it through C19 |
| ETag and `304` | Unchanged: `outlet_menus` and the four menu tables move the catalog version, so assigning, reordering, activating a menu, renaming or suspending a section changes the till tag and an earlier `If-None-Match` answers `200`. The `304` path reads no menu |
| Statements | Unchanged: 8 for the `200` (9 with images) and 7 for the `304`, guards, `BEGIN` and `COMMIT` included; the menus are composed inside the existing read statement |
| SQL | One correlated aggregation from the outlet's assignment: the menu by key (`LATERAL ... LIMIT 1`), its active top sections and their subsections through `menu_sections_menu_parent_idx`, the entries of each section through `menu_entries_section_idx`, the selected variants through their primary key. Whether an entry's item sells is one lookup in a `jsonb` set built once from the statement's own `sold` set (an init plan, read only when an entry is judged), so the menus cost no second evaluation of the sellable rule and no join the planner could reorder |
| Plan on stale statistics | With 2,001 items, 10 menus of 1,000 entries (3 active, 3,000 shown) imported after the statistics were frozen: no sequential scan of a menu tree table, no looped scan wider than 100 rows, the widest menu lookup 18 rows, about 6 rows and 16 to 20 buffers per shown entry, also with `enable_nestloop = off` (the spec allows 14 and 40, about twice the measured). Known limit: when the statistics were taken while the menu tables were nearly empty (a new database), PostgreSQL may read the top sections of a menu through the primary key with a filter, every section of the table per menu (520 rows per loop measured, with the same cost per entry); the spec seeds a neighbour organization with menus so its statistics are not near-empty, as a running database has |
| Payload | A restaurant of 300 items with two menus showing 240 entries, half of them with two selected variants: the menus add 25.1 KB to 276.2 KB of categories, items and groups (+9.1 %) |
| Latency | Same fixture, median of 15 runs on a loaded development machine: execution 18.6 → 21.6 ms and planning 3.4 → 4.2 ms. An outlet without menus pays only the planning of the menus part and one read of its (empty) assignment |
| Front end | Group the screen by `menus`, then `sections` and `subsections`; render each entry from `items` by `itemId`, limited to `variantIds` when present; when `menus` is empty group by `categories` as before. Items outside every menu still sell through the search and the scan |

OpenAPI moves to `13.2.0`: `npm run openapi:compat` against `origin/staging`
finds no breaking change; the additive ones are the M2a path and the
`menus` property of the `200` of `GET /catalog/outlets/{outletId}/sellable-items`.

***

### K16 as built: the items an outlet does not sell (migration `0106`, baseline revision 116)

Two operations are added (Catalog 44 to 46). Several outlets may share one
business unit, and nothing said "this item is not sold at this outlet".

#### C30 — Choose which items of its unit assortment an outlet does not sell

**Owner decisions.** The catalog is abstract and administered per business
unit or per outlet, without per-channel flags (OV24). 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 only hide: it never sells
what its unit does not list. Hiding is by item, so all its variants are
hidden. Menus stay presentation and never limit what sells.
**Actor:** the read needs `catalog.items.view` or `catalog.assortment.manage`
at the outlet; the write needs `catalog.assortment.manage` at the outlet
(`decideAtOutlet`, the same gate as C29, now one shared function).
**Plan:** like C29, any set but the empty one is gated by `catalog.items`, and
an outlet that is not operating answers `422 · authorization.outlet_not_operational`;
the empty set is never gated.
**Routes:** `GET /catalog/outlets/{outletId}/hidden-items` and
`PUT /catalog/outlets/{outletId}/hidden-items` with `{ "itemIds": [...] }`.

```json
PUT /api/v1/catalog/outlets/0193…e1/hidden-items
If-Match: "<the ETag of GET /catalog/outlets/0193…e1/hidden-items>"
{
  "itemIds": ["01930000-0000-7000-8000-0000000000a1", "01930000-0000-7000-8000-0000000000a3"]
}
```

```json
200 OK
ETag: "<a new strong tag>"
{
  "data": {
    "outletId": "01930000-0000-7000-8000-0000000000e1",
    "items": [
      { "itemId": "01930000-0000-7000-8000-0000000000a3", "code": "ensalada-cesar", "name": "Ensalada César", "status": "active" },
      { "itemId": "01930000-0000-7000-8000-0000000000a1", "code": "hamburguesa-clasica", "name": "Hamburguesa clásica", "status": "active" }
    ]
  },
  "meta": {}
}
```

| C30 | As built |
|---|---|
| Answer | `{ outletId, items: [{ itemId, code, name, status }] }` by name; an outlet that hides nothing answers `items: []`. The write answers the same body as the read, with the new tag. The item reference is the one every other catalog route uses |
| Body | `itemIds`, required, 0 to 500 item ids (`OUTLET_HIDDEN_ITEM_LIMIT`: a restaurant outlet hides tens and a store a few hundred; the bound keeps the set one read under one tag), each once; a 501st answers `400` at `itemIds`, a repetition `400` at `itemIds[i]`, any other field `400` |
| Write shape | The whole set is replaced under the tag of the set that was read (`428` without `If-Match`, `412` when it changed), like C29 and the structure of a menu. The assortment of a unit uses the delta `listing-changes` because its set is unbounded; this set is bounded, so no second write route exists |
| Judgement | Only an item that starts being hidden is judged: unknown or of another organization `404 · catalog.not_found` with `meta.errors[].field` such as `itemIds[1]` and a message that says how to fix it. An item of any status can be hidden; one archived later stays in the answer with its status until it is taken out |
| No-op | The same set in any order answers `200` with nothing written, no record and the catalog version unchanged |
| Effect | The item leaves the till list (C17), the search (C19), the scan (C18) and `CatalogSellableContract.resolve` and `admit` (given an `outletId`, refused `not_listed`) of that outlet only; a combo whose component it hides is incomplete there exactly like one with an unlisted component; a menu entry of the item leaves that outlet's menus. A sibling outlet of the same unit is unaffected; the rows stay when the outlet moves to another unit |
| Outlet in the contract | `CatalogSellableContract.resolve` and `admit` take an optional `outletId`. Given one, they judge in the same statement that the outlet exists in the organization and is operated by the given business unit on the date of its site; otherwise the verdict or the admission is refused with `outlet_not_operated_by_unit` (right after `business_unit_unavailable`), so a wrong outlet can never skip the hidden items. Without an `outletId` they judge the unit alone, which is a documented mode of the contract. `catalog.variant_is_listed_at_outlet` answers false in the same cases |
| A set, not a list | An item sent twice is refused by the schema at the position of the repeat (`400`, `itemIds[i]`); the use case, which other callers reach, compares, writes and records the distinct items in the order first sent, so `[A, A]` over a stored `[A]` is a no-op |
| One fragment | `hiddenAtOutlet` in `sellable-rule.queries.ts`, a keyed anti-lookup per candidate item on the primary key `(organization_id, outlet_id, item_id)`, composed once by `listedInScope`; the plan specs of the till keep their original ceilings with 5 % of the items hidden |
| ETag and `304` | The strong tag of the set (`contentEntityTag`); the till tag moves with every write because `outlet_hidden_items` bumps the catalog version, so the tag the till sent before no longer answers `304` |
| Locks and writes | The catalog version row `FOR UPDATE` first (archiving an item takes it first too), then the stored set, then the new items only, then one `DELETE` of the outlet's rows and one `INSERT` |
| Audit | One `catalog.outlet.hidden_items_set` record (resource kind `organization.outlet`, the outlet id) per real change, with `catalog.outlet.hidden_items` before and after as the comma-separated sorted ids (`null` for none); refusals are audited |
| Table | `catalog.outlet_hidden_items(organization_id, outlet_id, item_id, created_at)`, primary key `(organization_id, outlet_id, item_id)`, composite foreign keys to the outlet and the item; runtime `SELECT`, `INSERT`, `DELETE` only |
| SQL | `catalog.variant_is_listed_at_outlet(org, unit, outlet, variant)` is `variant_is_listed` plus "the unit operates the outlet today" and the hiding check, and fails closed; the Sales order-line trigger keeps the unit-keyed functions until Sales decides to use it |
| OpenAPI | Moves to `13.3.0` (additive): `openapi:compat` against `origin/staging` finds no breaking change |
| Front end | Read the set, add or remove items on screen, send the whole set with the tag read; refresh the tag after any write |

#### C31 — Choose the hours in which an outlet shows each of its menus

**Owner decisions.** A business shows different menus at different times at an
outlet ("Breakfast" until 11:00, "Lunch" on certain days). Menus only organize
the till screen and never limit what is sold: `admit` ignores menus. The
catalog stays abstract, with no per-channel flag (OV18, OV24). A window decides
which of the menus assigned to an outlet (C29) its till shows *now*. The window
belongs to the assignment of the menu to the outlet, not to the menu: the same
"Breakfast" may open 06:00 to 11:00 at outlet A and 07:00 to 12:00 at outlet B.
A menu with no window is always shown. Holidays and date ranges are out of
scope (deferred).
**Actor, plan and closed outlet:** exactly C29 (same gate, same plan rule, same
`authorization.outlet_not_operational`).
**Routes:** no new route. The windows ride on `GET` and `PUT
/catalog/outlets/{outletId}/menus` (C29): one write route for one use case, one
`If-Match`, one tag and one audit record. The request keeps `menuIds` (the set
and the order) and gains an optional `windows` list; every menu of the answer
gains `windows`. A separate `/menu-windows` route was rejected: two writes for
one use case, with two `If-Match` and two records. Replacing `menuIds` with
`menus: [{ menuId, windows }]` was rejected: it breaks the contract of M2a.

```json
PUT /api/v1/catalog/outlets/0193…e1/menus
If-Match: "<the ETag of GET /catalog/outlets/0193…e1/menus>"
{
  "menuIds": ["01930000-0000-7000-8000-0000000000d0", "01930000-0000-7000-8000-0000000000d9"],
  "windows": [
    { "menuId": "01930000-0000-7000-8000-0000000000d0", "days": [1, 2, 3, 4, 5], "from": "12:00", "until": "16:00" },
    { "menuId": "01930000-0000-7000-8000-0000000000d9", "from": "22:00", "until": "02:00" }
  ]
}
```

```json
200 OK
ETag: "<a new strong tag>"
{
  "data": {
    "outletId": "01930000-0000-7000-8000-0000000000e1",
    "menus": [
      { "menuId": "01930000-0000-7000-8000-0000000000d0", "code": "almuerzo", "name": "Menú del almuerzo", "status": "active",
        "windows": [{ "days": [1, 2, 3, 4, 5], "from": "12:00", "until": "16:00" }] },
      { "menuId": "01930000-0000-7000-8000-0000000000d9", "code": "bebidas", "name": "Bebidas", "status": "active",
        "windows": [{ "days": [1, 2, 3, 4, 5, 6, 7], "from": "22:00", "until": "02:00" }] }
    ]
  },
  "meta": {}
}
```

| C31 | As built |
|---|---|
| Window | `days` (1 Monday to 7 Sunday, each once, every day when omitted), `from` (inclusive) and `until` (exclusive) as `HH:MM`, in the local time of the site of the outlet. `until` earlier than `from` crosses midnight and the window belongs to the weekday it starts on (Friday 22:00 to 02:00 ends on Saturday at 02:00). `from` equal to `until` is refused |
| Body | `windows` is optional; each entry names its `menuId`, which must be in `menuIds`. At most 14 windows per menu (`MENU_WINDOW_LIMIT`: two service periods on each of seven weekdays), 140 in all. A body without `windows`, the form that predates them, shows every menu always and so replaces the windows the outlet had: the write is a whole-set `PUT`, so send the windows read from the `GET` again to keep them |
| Validation | `400` with a pointer that teaches: an hour that is not `HH:MM` (`windows[0].from`), `from` equal to `until` (`windows[0].until`), no day, a day outside 1 to 7 or a repeated day (`windows[0].days[1]`), a window of a menu that is not in `menuIds` (`windows[1].menuId`), more than 14 windows for a menu (`windows[14]`), any other field |
| Overlap | Windows of one menu may touch (one ends when the next starts) but not share a minute of the week, midnight and Sunday to Monday included: `422 · catalog.menu_windows_overlap` with `meta.errors` naming each window that overlaps an earlier one (`windows[2]`). Refused rather than merged: two windows that share a minute are almost always a typing mistake. Judged also when no menu is new |
| Answer | Each menu carries `windows: [{ days, from, until }]` in canonical order (by `from`, then `until`, then `days`), days ascending; `[]` when the menu is always shown. The tag of the read is the strong hash of the answer, so a window change moves it |
| No-op | The same windows, in any order and with the days in any order, answer `200` with nothing written, no record and the catalog version unchanged |
| Writes | As C29 plus, when the outlet has or gets windows, one `DELETE` of the weekdays and one of the windows before the assignment is deleted, and one `INSERT` of the windows and one of the weekdays after it is inserted (no foreign key cascades). Each statement moves the catalog version once |
| Audit | The same `catalog.outlet.menus_set` record per real change, with `catalog.outlet.menus` (only when the ids or their order moved) and `catalog.outlet.menu_windows` (only when the windows moved) before and after; the windows read `<menuId> <days> <from>-<until>` joined by `; ` (`null` for none). No new action code |
| Tables | `catalog.outlet_menu_windows(organization_id, outlet_id, menu_id, window_number, time_from, time_until, spans_midnight, created_at)`, primary key on the first four, composite foreign key to `outlet_menus(organization_id, outlet_id, menu_id)`, number 0 to 13, and the time check of Pricing (`from <> until`, `spans_midnight` exactly when `from > until`) without any foreign key to Pricing; `catalog.outlet_menu_window_weekdays(…, window_number, iso_weekday 1 to 7)` with a composite foreign key to the window. Statement-level version triggers, the organization reassignment guard, RLS and `SELECT`, `INSERT`, `DELETE` for the runtime. Contract: `database/tests/contracts/catalog-outlet-menu-windows-contracts.ts` |
| Rejected shapes | Columns on `outlet_menus` (a menu needs several windows and each window a repeating set of weekdays), a column on `menus` (the menu is organization-wide), and weekdays as an array column (no foreign key, no check per element, nothing to probe) |
| Front end | Read the menus with their windows, edit them on screen, send `menuIds` and `windows` with the tag read; a menu without a window is always shown |

**The till read.** `GET /catalog/outlets/{outletId}/sellable-items` (C17) keeps
only the menus whose window is open at the instant of the read, in the outlet
order, and a menu without a window always; `menus: []` when none is open and
nothing else is blocked. The server evaluates, in SQL, in the local time of the
site of the outlet (`organization.outlet_operating_context` returns
`site_timezone`), at an instant that a `Clock` (`src/foundation/time`) gives the
use case once per request and the use case passes to both statements of the
read; tests inject it. The comparison is on the wall clock, so a daylight
saving change moves a window with the clock, and the autumn hour that happens
twice is open twice. `menusValidUntil` (ISO-8601 UTC, in `data` next to
`menus`, absent when no window will ever change the set) is the next instant at
which a menu opens or closes: the till refreshes then. It is the earliest
boundary that only one side owns (the start of a window of a menu that is no
window's end, or the other way round), so touching windows name no change. The
wall clock advances at the pace of the instant until the zone changes its UTC
offset, so the boundary is the instant plus the wall-clock distance to it, with
no conversion of an ambiguous or skipped local time. The first change of UTC
offset of the zone after the instant (found on a 15-minute grid, PostgreSQL's
`AT TIME ZONE` on the site zone) caps it: the clock jumps there, and the set can
change there (the autumn clock going back to 01:00 closes a window that opened
at 01:30 in the first pass, and reopens it in the second), so `menusValidUntil`
is the least of both and names that instant even when the set does not change
then, which only brings a harmless early refresh; it is never later than the
true change, in the first or the second pass of the repeated hour and when a
window starts in the hour a spring change skips. The tag and the menus are
exact at every instant.

| C17 menus and time | As built |
|---|---|
| ETag | `c<catalog>-s<structure>-<unit or none>-d<delivery>-m<mask>[-u<instant>]`. `m` is the set of open menus as a bit mask over their positions, in hexadecimal (exact, not a hash: a menu keeps its position until a write, and a write moves the catalog version); `u` is `menusValidUntil`. It moves with the clock and without any write, answers `304` for the same set, and never for a stale one. Two days with the same open menus still get different tags because the body differs |
| One statement | The statement that already resolves the outlet state also returns the mask and the next change, so a `304` costs no new statement (`catalog-till-menus-statement-budget.e2e-spec.ts` is unchanged). The search keeps its lean statement and carries no menus |
| Plan | Every window probe is a keyed lookup on the primary keys; the plan specs keep their ceilings and read no window table whole, with 150 neighbour outlets so the frozen statistics see a real table |
| OpenAPI | Moves to `13.4.0` (additive): `windows` in the request and in each menu of the answer, `menusValidUntil` in the sellables answer, `catalog.menu_windows_overlap` in the `422` |

### K16b as built: option targets are judged by the listing and the hiding (no migration)

**Owner decision.** A local never sells what its business unit does not list
or what its outlet hides. K16 left the modifier options whose target is an
item outside that rule ("kept on purpose"); this slice changes it, and
corrects the claim of PR #597, which said that a combo or an option whose
component is hidden at an outlet was unavailable there: for options it was not.

**The rule.** An option is available when it is active and, when it targets a
variant, that variant is an active variant of an active item that the unit
lists, the outlet does not hide and that opens no combo group (`targetSellable`:
the listing and hiding fragments of the sellable rule, written once). An
option sells a plain item: a combo, composable or not, is not available as an
option target. The judgement is made inside the statement that reads the data,
so there is no window between the check and the answer. A group is offered when
one of its options is available; a required group counts only the units of its
available options; the defaults, quantities and option limits of an offer leave
with the option; the item whose required group lost all its options is left out
of the lists, the search and the scan (`required_modifier_unsatisfiable` at
`resolve`) and is refused at `admit`; selecting an option that left is refused
`option_unavailable`. Without an `outletId`, `resolve` and `admit` judge the
unit alone, so only the delisting applies.

**Where it lives.** `availableOption(alias, scope)` in
`sellable-rule.queries.ts` has three modes. With `inline` it judges the target
where the option is read: the scan and the filter of the search, which look at
the few options of the items they examine, so the work follows the page and not
the organization. With `targets` it asks a `jsonb` set of sellable targets that
the statement builds once with `optionTargetSet` (`option_targets`: the list,
for every active target of the organization; `resolve`, for the options of the
items it serves and of the components of their combos; `page_targets`: the
enriched page of the search, for the options of the items on the page), as a
keyed lookup. Without either it is the structural `activeOption`. The options of
the offering item and of the components of its combo (level 1) are judged by the
scope; those of deeper components, and those of a target, structurally. The
writes (`SHARE_LOCK_GROUPS`, the defaults of an offer) keep the structural rule:
they have no unit.

**The write path (C12).** Creating a group or setting its options refuses a new
option whose `variantId` belongs to a combo, an item with a live bundle group
(the fragment `opensAGroup` the read uses): `422 · catalog.option_target_not_plain`,
at `options[i].variantId` in the set and without a field in the create, after
`404 · catalog.not_found` and `422 · catalog.variant_not_active`. The check runs
inside the transaction of the write: the variants and their items are
share-locked, and the plain flag comes back in that same statement, so the
write costs no extra statement. The composition command locks the item row for
update, so it waits for the write that holds the share lock; a composition that
commits between the snapshot of that statement and its lock is the same as one
that composes right after the write, which the reads already neutralize. A kept option never changes its variant, so it is not
judged again. OpenAPI moves to `13.8.0` (additive: the new code on the two
routes).

**Existing rows.** Options written before this rule that point to a combo stop
being offered at the next read; nothing rewrites them. The repository holds no
seed, fixture or test with a combo as an option target (an offering item that is
a combo is a different thing). No database constraint backs the rule, because it
would need a migration; a plain item composed into a combo after it became a
target stops being offered in the same way.

**The item read (C3)** is unchanged byte for byte: it shows the stored status
of each option and of its target, never availability, which depends on a unit
and an outlet.

**Known limit.** The Sales order-line trigger is unit-keyed
(`catalog.assert_variant_listed`) and does not enforce the hiding of an outlet
nor the listing of an option target. Moving it to
`catalog.variant_is_listed_at_outlet` is a Sales decision (R19).

**Cost.** Measured in `PERFORMANCE_BASELINE.md`, section 20.

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

No operation is added (Catalog stays at 46). C13 gains `defaultQuantities` and
`admit` (C20) answers `reducedDefaults` for real; the design is in
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K14a as built" (OV16).

| C13 default quantities | As built |
|---|---|
| Schema | `catalog.item_modifier_group_defaults.quantity` (`integer NOT NULL DEFAULT 1`, check 1 to 999); `catalog.item_modifier_group_option_overrides` is created for K14b and nothing reads it yet |
| Request | Each offered group, on the item and on a variant override, may send `defaultQuantities`: `{ "<optionId>": 2 }`, integers 1 to 999, keys among `defaultOptionIds`; absent or empty counts every default as one unit |
| Rules | `422 · catalog.default_quantities_invalid` pointing at `offers[i].defaultQuantities` (or `variantOverrides[j].offers[i].defaultQuantities`) when a quantity is above the `maxQuantity` of its option or the quantities of all the defaults add up to more than `maxSelections`; the defaults are judged first; a key that is not in `defaultOptionIds` is a `400` at `defaultQuantities.<optionId>` |
| Change detection | A quantity-only edit is a change: the offer, its audit summary (`o1*3`) and the item tag move; a replay writes nothing; the plan is asked, lowering included |
| Reads | `defaultQuantities` appears on the item read (offers and variant overrides), on the offers of a group (C12), on the till list, search and scan and in the `resolve` snapshot only when some default starts above one unit, so every payload without quantities is byte-identical |
| `admit` | A default counts its quantity: `reducedDefaults` names one chosen with less, `removedDefaults` one left out (its quantity as `defaultQuantity`); an unavailable default is never named and a preselection is never added |
| Option writes | They still do not judge offers again; the till reads `least(default quantity, option maxQuantity)`, so a lowered option maximum never makes a till preselect more than the option allows |
| OpenAPI | Moves to `13.5.0` (additive): `defaultQuantities` in the request and in the answers that carry `defaultOptionIds`, `catalog.default_quantities_invalid` in the `422` |

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

No operation is added (Catalog stays at 46). C13 gains `optionLimits`; the
table `catalog.item_modifier_group_option_overrides` that migration `0108`
created is now read and written. The design is in
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K14b as built" (OV16).

| C13 option limits | As built |
|---|---|
| Request | Each offered group, on the item and on a variant override, may send `optionLimits`: `{ "<optionId>": 1 }`, integers 0 to 999, option ids lowercase; absent or empty limits nothing. A limit is the most units of that option this offer allows; 0 withholds the option from this offer. The option itself, and every other offer of it, is untouched |
| Rules | `422 · catalog.option_limits_invalid` pointing at `offers[i].optionLimits.<optionId>` (or `variantOverrides[j].offers[i].optionLimits.<optionId>`) when a limit is equal to or above the `maxQuantity` of its option (it would raise or do nothing) or names an option that is not an available option of the group; the message names the option and its real maximum. The limits are judged first |
| Interplay | The minimum is judged against the units the limited options can still reach (the sum of the lower of each option maximum and its limit, an option limited to 0 adds nothing): `422 · catalog.selection_rule_invalid` as before. A default cannot be an option limited to 0 (`catalog.default_options_invalid`) and a default quantity cannot exceed the limit (`catalog.default_quantities_invalid`) |
| Stored limits | A limit already stored for the same option in the same offer is never judged again, so reading an offer and sending it back works after the option was lowered or retired (the limit is then moot: the effective maximum is the lower of the two) |
| Change detection | A limit-only edit is a change: the offer, its audit summary (`;limits=o1=1+o2=0` after the status and the defaults) and the item tag move; a replay writes nothing; the plan is asked, lifting limits included |
| Write | The limits are replaced as a set in the same transaction as the offer: the limits of the scope are deleted and the sent ones inserted, after the defaults; a group that leaves the list loses its limits. The lock order is unchanged |
| Reads | `optionLimits` appears on the item read (offers and variant overrides), on the offers of a group (C12), on the till list and the search (the scan only judges sellability) only when the offer limits an option, so every payload without limits is byte-identical. The till carries only the limits of available options that really lower the option: the client takes the lower of the option `maxQuantity` and the limit of the offer, and an option limited to 0 is not offered there |
| `resolve` and `admit` | Each effective offer lists only the options it still allows: an option limited to 0 is absent, the others carry the effective maximum, so choosing the excluded one is `option_not_offered` and choosing more than the effective maximum `option_quantity_above_maximum`; an offer left with no option is not offered. An option belongs to one group and a variant has one effective offer per group, so the per-option map of the selection judgement is already per offer. A required offer whose limited options cannot reach its minimum makes the variant unsellable (`required_modifier_unsatisfiable`) in the till, the search, the scan, `resolve` and `admit` |
| Option writes | They still do not judge offers again: lowering an option below an existing limit makes the limit moot. Sales and Commerce triggers check only the option's own maximum, which a limit never exceeds |
| OpenAPI | Moves to `13.6.0` (additive): `optionLimits` in the request and in the answers that carry the offer, `catalog.option_limits_invalid` in the `422` |

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

One operation is added, `GET /catalog/variant-dimensions` (C32; Catalog goes from
46 to 47); the matrix, the item read and the item list gain fields and a filter. The design is in
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), section 8 and "K13 as built"
(H6); the tables are justified in `DECISIONS.md` D34.

| C6 matrix with dimensions | As built |
|---|---|
| Schema | Four tables in `catalog`: `variant_dimensions`, `variant_dimension_values`, `item_variant_dimensions` and `item_variant_dimension_values` (columns, keys and grants in `database/README.md`, revision 119). Runtime `SELECT` and `INSERT` only: nothing is renamed, archived or removed yet, so no `UPDATE` or `DELETE` is granted |
| Dimension identity | A dimension belongs to the catalog, not to an item; its code is the name reduced to lowercase letters and digits joined by hyphens (`Talla`, `TALLA` and `tallá` are `talla`; `dimension` when nothing is left) and is unique per catalog ignoring case. A value is found by its code ignoring case inside the dimension. A dimension or value that exists keeps its stored name; the request name is used only when it is created |
| Fixed dimensions | The first matrix of an item attaches its dimensions in the order sent (positions 0 to 2; at most three by the position check). A later matrix must name the same dimensions in the same order, and may add values; any other set, order or count answers `422 · catalog.variant_dimensions_locked`, whose detail names the dimensions of the item |
| Linking | A cell whose code exists, not archived, with exactly the name, SKU and quantity policy of the cell and no links is linked (its id, barcodes and images are untouched); one already linked to the same values is in effect; one with another profile, archived or linked to other values is `409 · catalog.code_taken`. A matrix that creates variants answers `201`; one that only links answers `200` with the new tag; one with nothing to do answers `200` and writes and records nothing |
| Combination | Two active variants of an item cannot hold the same set of values: the database refuses it (a deferred constraint trigger on the links, which the matrix settles with `SET CONSTRAINTS ... IMMEDIATE` before it answers, and an immediate trigger on a variant that becomes active, which fails inside the `UPDATE` of its status and adds no statement), so the failure is `409 · catalog.variant_combination_taken` for the matrix and for the `reactivate` transition. Variants without links are never compared |
| Statements and locks | Fixed per command whatever the number of cells: the dimensions of the item, the known dimensions and values (one statement), at most one insert each of dimensions, values and item dimensions, the variants and the links. Lock order unchanged (catalog version row, item row `FOR UPDATE`); the combination check takes the item row `FOR NO KEY UPDATE`, the same row, so it adds no new ordering |
| Audit | `catalog.item.variant_added`, with the changes it had plus `catalog.item.variant_dimensions` (the codes the first matrix fixed, in order), `catalog.item.variants_created` and `catalog.item.variants_linked`. No new action type was needed |
| Item read | Each variant that has links answers `dimensionValues: [{ dimension: { code, name }, value: { code, name } }]` in the order of the dimensions of the item, built once per statement for the items asked (`variant_links`, `labels` and `item_dimension_entries` CTEs of the same single statement); a variant without links does not carry the field, so the payload and the tag of every item that never had a matrix are byte-identical (the rich item golden file did not change). The till and sellable payloads are unchanged: the variant code and name remain what they sell by |
| List (C32) | `GET /catalog/variant-dimensions` (optional `businessUnitId`, as the item read) answers every active dimension of the default catalog ordered by the position of the matrix that created it and then by name: `{ code, name, activeItemCount, values: [{ code, name }] }`, the values active and in the order they were first sent, the count only of active items that a matrix gave the dimension. `catalog.items.view`, never the plan, nothing recorded; `Cache-Control: private, no-cache` and a strong content tag with `304`. One statement: the count visits each active item of the organization once and probes its dimensions by the key of the item (at most three), so it costs a few buffers per item and the plan spec bounds it; the answer is not capped because a catalog reuses a small vocabulary (a matrix creates at most three dimensions and fifty values per command). No new permission, grant or audit action |
| Filter (C2) | `GET /catalog/items?variantValue=<dimension code>:<value code>`, repeatable up to three times for different dimensions: the items that have an **active variant carrying every value** (one variant must hold all of them; a size of one variant and a color of another do not match), codes compared without case, an unknown code listing nothing. It is one more candidate beside `q` and `categoryId` in the same `id IN (...)` shape: the value is found by the code of its dimension (the catalog case-insensitive unique index) and of its value, the links are read by `item_variant_dimension_values_value_idx`, the variant status by its key and each further value by the primary key of the links (`variant, dimension`), so the plan spec keeps it keyed and inside a ceiling of its own, 5 rows and buffers per imported item against the 4 of the other filters (measured 3.3 to 3.8 over three runs). The filter joins the cursor fingerprint (sorted pairs, only when present, so every other cursor is unchanged). A malformed value, more than three or one dimension twice answers `400` with a message that shows `variantValue=talla:m` |
| OpenAPI | Moves to `13.7.0` (additive): `GET /catalog/variant-dimensions`, `dimensionValues` in the answers that carry an item, `catalog.variant_dimensions_locked` in the `422` and `catalog.variant_combination_taken` in the `409` of the matrix, and a `409` on the variant transitions |

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

No operation is added: the options of a group are written by the existing
`PUT /catalog/modifier-groups/{modifierGroupId}` (C12) and, for a new group, by
`POST /catalog/modifier-groups`. The design is in
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K14c as built" (OV13, OV14);
the decision is `DECISIONS.md` D36.

| C12 option presentation | As built |
|---|---|
| Schema | Four columns of `catalog.modifier_options` and five new `CHECK`s (columns and constraints in `database/README.md`, revision 120). No table, no grant, no trigger |
| Request | Each option of the set accepts `kind` (`instruction`, `catalog_variant`, `text`), `textMaxLength` (1 to 500), `prefix` (`add`, `extra`, `light`, `no`, `on_the_side`, `substitute`), `printsOnReceipt` and `printsOnKitchenTicket` (booleans). A field left out is its default: no length, no prefix, both hints `true` |
| Judgement | The domain judges every option of the sent set before any write and answers `422 · catalog.modifier_option_presentation_invalid` listing each option by name and the rule it breaks, at the field (`options[2].textMaxLength`): a text option needs a length and exactly one unit and takes no prefix or `add`; a length belongs to text options only; `light`, `no`, `on_the_side` and `substitute` need `maxQuantity` 1; `no`, `on_the_side` and `substitute` apply to instruction options only. The same judgement runs on the group create |
| Offers | Per K5 an option write does not judge the offers that hold the option; the prefix rules are judged when the option is written |
| Answers | The group read, the group write and the item read answer `textMaxLength`, `prefix`, `printsOnReceipt: false` and `printsOnKitchenTicket: false` only when they differ from the default, so every payload and tag of existing data is byte-identical (the rich item golden file did not change) |
| Audit | The option set audit change carries the new fields only when an option holds a non-default value, so records and comparisons of existing groups are unchanged |
| OpenAPI | Moves to `13.9.0` (additive, after the `13.8.0` of the option target listing): the option fields above and the `422` code |

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

No operation is added. The design is in
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K14d as built".

| C17, C20 text selections | As built |
|---|---|
| Till read | `modifierGroups[].options[]` answers `textMaxLength`, `prefix`, `printsOnReceipt: false` and `printsOnKitchenTicket: false` only when they differ from the default; a text option answers `kind: "text"`. The payload, the entity tag and the golden file of a catalog without text options are byte-identical |
| `resolve` and `admit` | The option of an effective offer carries the same fields under the same rule. A selection of a text option carries `text`; `admit` normalizes it to NFC and answers it in the admitted selection |
| Judging | `text_required`, `text_not_allowed`, `text_invalid` (blank, NUL, any control character) and `text_too_long` (beyond `textMaxLength`, 500 at most), naming the option; a quantity above the option maximum (1 for a text option) is refused first. More than 10 selections with text in a line, those of a combo's components included, is the request problem `too_many_texts` before any lock |
| Statements | Unchanged: the four columns join the existing `SELECT`s |
| Sales | Must add a `text` branch to its kind check and a column for the value before it consumes text options (X10, X3) |

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

No operation is added. The design is in
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K15a as built"; the
decision for the flags table is `DECISIONS.md` D37.

| Piece | As built |
|---|---|
| Schema | Three description checks, one quantity step check, two columns of `item_variants` with their check, and `catalog.item_regulatory_flags` (columns and grants in `database/README.md`, revision 121) |
| C4, C5 regulatory flags | `regulatoryFlags` (a list of `age_restricted_alcohol`, `age_restricted_tobacco`, `prescription_only`, `pharmacy_only`, `hazardous_goods`, each once) on the create and on `PATCH /catalog/items/{itemId}`, where it replaces the set and an empty list clears it; `400` for an unknown, repeated or `null` value. The item read answers `regulatoryFlags` only on an item that has any, so every other payload, tag and golden file is byte-identical. Catalog enforces nothing: a flagged item is still sold |
| C4, C6 net content | `netContentQuantity` (greater than zero) with `netContentUnitCode` on `defaultVariant` of the create, on the add of a variant and on its edit (`null` in both clears them). `400` when only one is sent; `422 · catalog.unit_unknown` when the unit is not a reference unit of measure. Answered only on a variant that has one |
| C6 quantity policy | A minimum or a maximum that is not a multiple of the step is `422 · catalog.quantity_policy_invalid` |
| Descriptions | The 2000 character bound already applied by the API now holds in the database too; it adds no refusal |
| Audit | The item edit holds the profile and the flag changes in one record; the variant edit names the two net content fields |
| OpenAPI | Still `13.9.0` (additive): `regulatoryFlags` on the item create, edit and answers, `netContentQuantity` and `netContentUnitCode` on the variant requests and answers, and `catalog.net_content_invalid` |

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

Two operations. Design in [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K12b as built"; decision `DECISIONS.md` D41.

| Piece | As built |
|---|---|
| Open | `GET /catalog/outlets/{outletId}/bootstrap` answers `data: { cut, catalogVersion, businessUnitId, categories[], modifierGroups[] }` and `meta: { catalogVersion, media }`, read in one snapshot: every active category with its active ancestors and every active modifier group with its available options, whether or not an item offers it today (the device prunes), which keeps the opening a few milliseconds instead of evaluating the sold set. `cut` is an opaque value (`c<catalog version>-s<structure version>-<unit or none>`) the device sends unchanged on every page |
| Pages | `GET /catalog/outlets/{outletId}/bootstrap/items?cut=&limit=&after=`: the items the outlet sells, in the item shape of the sellables read with, on each variant, `sku` and `barcodes[]` (`barcodeId`, `barcode`, `symbology`, `isPrimary`; active barcodes only), ordered by name and id, keyset (`limit` 1 to 100, default 50), no ceiling on the number of items. `meta: { pageInfo, catalogVersion, cut, media }` |
| Cut moved | `409 · catalog.bootstrap_cut_moved` when the catalog version, the structure version or the unit that operates the outlet is no longer the one of `cut`; nothing is read. The device opens again |
| Bad input | `400` for a missing or malformed `cut`, a limit out of range, `before` or any unknown parameter, and a cursor issued for another cut, outlet, route or organization |
| Access | `catalog.sellables.view` at the outlet, decided on every call before the outlet is looked up (the till gate): a member without it, or whose scope moved, gets the same plain `403` for an existing, an unknown, a closed and a foreign outlet; only a caller holding it organization-wide learns `404 · organization.structure_not_found` |
| Scan index | The device builds `barcode -> variant` from the pages; the answer equals the online scan at the outlet (`GET .../barcode-matches`) for every barcode, and an archived or inactive barcode, a variant that is not active and an item the outlet does not sell are never in it |
| Applying the feed | Events of the organization with `catalogVersion` greater than the cut, in publication order; a `retired` or `removed` change deletes what its id names (an item with its variants and barcodes, a variant with its barcodes, a barcode); an `upserted` change rereads that aggregate. Serving the events and rereading by id belong to Sync |
| Not in the bootstrap | Product brands and variant dimensions (no till screen shows them and their changes write no event), and menus with their time windows (read online from the sellables read) |
| Statements | Open: 7 (session, membership, `BEGIN`, tenant context, permission, the read, `COMMIT`); a page: 8 (the same plus the cut check); a refused cut: 7 |
| OpenAPI | Moves to `13.10.0` (additive: two paths and the `409` code) |

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

No operation is added and the OpenAPI document does not change. Design in [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K12a as built"; decision `DECISIONS.md` D40.

| Piece | As built |
|---|---|
| What it is | Every catalog write that moves the catalog version also leaves, in the same transaction, typed events in `events.outbox_records`: `catalog.item.changed`, `.variant.changed`, `.barcode.changed`, `.offer.changed`, `.modifier.changed`, `.bundle.changed`, `.menu.changed`, `.category.changed` and `.listing.changed`. It is written by SQL triggers, so no command, route or use case changes |
| Payload | `{ schemaVersion: 1, catalogVersion, change, <id>, status?, retiredAt? }`; `change` is `upserted`, `retired` or `removed`; a retirement or a removal carries `retiredAt` (the tombstone). Contract: `src/modules/catalog/contracts/catalog-change-event.ts` |
| Which commands | All of them: the item, variant, barcode, category, modifier, offer, bundle, menu, assortment, hidden item, outlet menu and window commands, and the business unit registration (its default assortment is written when it commits). Retiring an offer, archiving an item or a barcode, and every other `archived` or `retired` transition write a tombstone |
| Rollback and retry | A command that fails after its write leaves no event, no outbox row and no version move; a replayed create (`Idempotent-Replayed: true`) writes nothing |
| Cost | No statement is added to any command (the triggers run inside the existing statements), so the statement budgets are unchanged; the latency of a write grows by the event inserts (`docs/operations/PERFORMANCE_BASELINE.md`, section 22) |
| Not served yet | Nothing reads the feed: the bootstrap and the delta read for a device are K12b and the Sync module |

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

No operation is added. Design in [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K15d as built"; decision `DECISIONS.md` D39.

| Piece | As built |
|---|---|
| Schema | `catalog.item_variant_identifiers` (columns and grants in `database/README.md`, revision 123) |
| Write | `identifiers` (a list of `{ scheme, value }`, scheme `mpn`, `isbn` or `manufacturer_code` each once, value 1 to 100 characters) on `defaultVariant` of `POST /catalog/items`, on `POST /catalog/items/{itemId}/variants` and on `PATCH .../variants/{variantId}`, where it replaces the set (`[]` clears it); `400` for an unknown or repeated scheme, a bad value or `null` |
| Read | `identifiers` on a variant only while it has any |
| Audit | The variant edit records `catalog.item.variant.identifiers` with the set before and after as `scheme:value` pairs |
| OpenAPI | Still `13.9.0`, additive |

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

Design in [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md), "K15c as built"; decision `DECISIONS.md` D38.

| Piece | As built |
|---|---|
| Schema | `catalog.product_brands` and `items.product_brand_id` (columns and grants in `database/README.md`, revision 122) |
| Brand routes | Five operations under `/catalog/product-brands`: create (idempotent), list (`status`), read, rename (`If-Match`) and `transitions` (archive, restore); `catalog.items.manage` organization-wide |
| Item side | `productBrandId` on the create and edit of an item (`null` clears); `404 · catalog.not_found` for an unknown or foreign brand; `422 · catalog.product_brand_archived`; the read answers `productBrand` only when set |
| List | `productBrandId` filters `GET /catalog/items`, archived brands included, and is bound into the cursor |
| Archived brand | An item that already has it keeps it; only giving it is refused |
| Audit | `catalog.product_brand` created, renamed, archived, restored; the item edit records `catalog.item.product_brand` with the ids |
| OpenAPI | Still `13.9.0`, additive |

## 10. Owner decisions

Ordered by when they are needed. **On 2026-10-04 the owner approved the
recommended default of OD1 to OD9**, so all nine are *Decided*, not open:
restaurant first; the item code optional and generated from the name; shared
products edited only by organization-wide members while business-unit
managers curate their unit's assortment; one catalog per organization now; a
new business unit sells everything, plus a workspace warning; the catalog
included in `pionero` without an item limit; categories now and menus later;
archived items restorable; the generic tag tables dropped. The options stay
below as the record of what was weighed.

| # | Needed before | Decision | Options and consequences | Decided (approved 2026-10-04) |
|---|---|---|---|---|
| **OD1** | K4/K5 order | Which vertical's needs ship first after the common core? | (a) Restaurant first: modifiers (K5) before barcodes (K4); a café can sell "latte, oat milk, large" sooner. (b) Retail first: barcodes before modifiers; a shop can scan sooner. Both are small; the order only moves a week | (a) Restaurant first, since restaurant is the product origin (`APP_SHELL_AND_ONBOARDING.md` reuses `ninaku-pos`) |
| **OD2** | K2 | Is the item `code` required from the person creating a product? | (a) Required: zero ambiguity, natural idempotency, but friction for a small restaurant that thinks in names. (b) Optional, server-generated from the name (`hamburguesa-clasica`, `-2` on collision): simple UX, needs G3 (`Idempotency-Key` + one column) | (b) Optional and generated; codes remain editable |
| **OD3** | K2/K6 | Who may edit the shared product data? | (a) Only organization-wide members (manager or owner assigned to the whole organization); business-unit managers curate their unit's assortment only. (b) Business units own their own products: needs an "owner business unit" on items or one catalog per unit (OD4) and makes shared items ambiguous | (a) Organization-wide edits, BU-level assortment |
| **OD4** | before C25 | One catalog per organization or several? | (a) One default catalog (v1). (b) Several catalogs assigned to business units (`access_mode = 'assigned'` already exists): real for a hotel group whose shop and restaurant share nothing, but doubles every screen's context | (a) One catalog now; several behind a separate capability later |
| **OD5** | K6 | What does a newly registered business unit sell? | (a) Everything in the organization catalog (current SQL default: no row = listed). (b) Nothing until someone lists items. (a) surprises a hotel whose new "Shop" unit suddenly sells restaurant dishes; (b) surprises a restaurant opening its second unit with an empty POS | **Superseded by D1 (revision 109)** for new units: `all` only for the first unit or one whose verticals already run in another unit, `none` otherwise (see "D1 as built"). Original decision: (a) Everything, plus a one-line warning in the workspace when an organization has more than one business unit with different verticals |
| **OD6** | K1 | Is the catalog part of the `pionero` plan, and is its size limited? | (a) Included, no item limit. (b) Included with a limit (e.g. 1,000 active items) via `catalog.active_items`. (c) A paid add-on | (a) Included, unlimited; a limit needs a business reason first |
| **OD7** | C21 | How does a restaurant POS organise products: by category or by menu? | (a) Categories only (v1, no new table). (b) Menus with sections and an outlet-menu assignment (G1): needed for "lunch menu vs dinner menu" and dayparts | (a) now; (b) when the first restaurant asks for two menus |
| **OD8** | K2 | Can an archived product be brought back? | (a) Yes, back to `inactive` (restoration). (b) No, archive is final; recreate instead (new variant ids break history continuity for reports) | (a) Restorable |
| **OD9** | K1 | Generic tags (`tags`, `tag_groups`, `item_tags`, `menu_tags`): drop? | (a) Drop in a reviewed migration. (b) Keep for a named use case (e.g. "vegan" filter, which `dietary_tags` already covers) | (a) Drop, unless the owner names a use |

**On 2026-10-05 the owner decided the category lifecycle** ("the best for
the business"): a category keeps its own status, its effective visibility
is derived from its ancestors, an inactive category stays organizable and
classifiable, an archived one is read-only. The ten rules are in §5.3,
"Category lifecycle and effective visibility".

Everything else in this document is *Decided* by the author and can be
changed by review without owner input.

***

## 11. Risks and what the existing SQL gets wrong or leaves open

| # | Finding | Evidence | Recommendation |
|---|---|---|---|
| R1 | **The runtime cannot reach the schema.** No `USAGE` on `catalog`; no table grants | `runtime_grants.sql` ≈ line 35 | K1, with contract tests for what runtime must *not* do (DELETE on master tables, EXECUTE on worker-only functions) |
| R2 | **Dual meaning of `business_unit_items`.** In `all` mode an inactive row excludes; in `selected` mode an active row includes. A row that is "active" in `all` mode does nothing | `variant_is_listed`, 4487–4502 | Encapsulate in C15; document in SQL test names; consider a `listing_kind` column later only if the dual meaning causes a bug |
| R3 | **Row-at-a-time listing predicate** unsuitable for the POS read | `variant_is_listed` is called per variant, including inside `resolve_barcode` | G4: set-based twin + equivalence test |
| R4 | **No `updated_at` maintenance**, and three tables without `updated_at` | no assignment in 0080; `catalogs` 4229, `business_unit_*` 4433/4446 | Application sets it; ETags never rely on it |
| R5 | **Organization-wide catalog version** invalidates every outlet's POS cache on any catalog edit | G2 design | Accepted: edits are rare relative to reads; a `200` re-read is ≤ 6 statements. Revisit with per-business-unit versions if `perf:http` shows refresh storms |
| R6 | **Item "default variant" deferred trigger** only fires at commit: a use case that forgets the default variant fails with `23514` at `COMMIT`, after audit and other writes | 2979–2991 | Domain rule in TypeScript prevents it before SQL; the trigger stays as backstop; map its constraint to `catalog.default_variant_required` |
| R7 | **READ COMMITTED is asserted** by `ensure_default_catalog`, `lock_hierarchy`, `guard_assortment_change`, `assert_variant_listed`, `resolve_barcode` | 4245, 2999, 4460, 4504, 4528 | Assumption: the tenant transaction runner uses READ COMMITTED (PostgreSQL default). K1 adds a test that would fail if it changed |
| R8 | **`resolve_barcode` takes `FOR SHARE` locks** even for a pure lookup | 4504–4545 | C18 uses a lock-free read query, and `admit` (K8) does not call it either: no application path uses it, but it stays with its grant (the database contracts exercise it, and its `assert_variant_listed` is called by the Sales schema's order-line trigger) |
| R9 | **`guard_assortment_change` locks the business unit row `FOR UPDATE` per row**; a 200-item listing locks the same row 200 times and contends with Organization structure writes on that unit | 4460–4485 | Bound calls to 200 ids; measure; acceptable for configuration traffic |
| R10 | **`catalogs.code` check is weaker** than every other code (only non-blank, no pattern) | 4229–4243 | Tighten in a later migration when additional catalogs ship (OD4) |
| R11 | **Generic tags have no reader** | §3.1 | OD9 |
| R12 | **Food data shape may move** (`catalog_food`) while production, crm and sales already reference `allergens`/`dietary_tags` | `DOMAIN_MAP.md` lines 38, 78; FKs in 0110, 0180, 0200 | Decide the boundary before C24; do not expose these tables before |
| R13 | **No menu-to-outlet assignment** (closed: M2a assigns menus to outlets, M2b reads them in the till; kept as the original design finding) | G1 | OD7 |
| R14 | **`modifier_groups` has no default selection rule**: min/max live only on each item link, so "Choose a side (1 required)" is repeated per item | 1672 vs 1937 | Accepted for v1 (one place to read); a group-level default can be added if owners complain |
| R15 | **Canonical SQL keeps comments**; new migrations cannot add any (`lint:no-comments`, shrinking allowlist) | `AGENTS.md` §4 | K1 SQL is comment-free; intent lives in test names and this document |
| R16 | **Audit retention class** for tenant configuration actions: organization actions use `administration.platform` | migration `0039` | Assumption: reuse it for catalog `configuration` actions unless the audit owner defines a tenant configuration class |
| R17 | **Deleting link rows.** `item_categories` and `item_modifier_groups` have `FOR ALL` policies, so DELETE is RLS-allowed once granted (since revision 101 only `item_categories` is granted: K9c); the audit trail must carry before/after since the rows vanish | 3980, `item_modifier_groups_tenant_policy` | C8 and C13 audit the complete before/after lists |
| R19 | **The Sales order-line trigger is unit-keyed.** It calls `catalog.assert_variant_listed`, which does not know the outlet, so it enforces neither the hiding of an outlet nor the listing of an option target | `0110_sales.sql`; K16, K16b | Known limit, a Sales decision: Catalog refuses at `resolve` and `admit`, which Sales calls first; moving the trigger to `catalog.variant_is_listed_at_outlet` is Sales' |
| R18 | `organization.effective_business_unit` returns `NULL` for an outlet with no active assignment on the date; C17 and C19 answer an empty list (not `500`) for a caller the permission step admits | `0020_organization.sql`, function and comment (verified) | K7 test rows (C17, C19) |

What this document could not verify: the full body of
`catalog.check_item_default_variant` (only its constraint-trigger
declaration was read), the retention classes available in `audit`, and any
real tenant cardinalities (§8.1 is an estimate).

***

## 12. Glossary

| Term | Meaning here |
|---|---|
| **Catalog (namespace)** | A `catalog.catalogs` row grouping master data; v1 uses only the organization's default one |
| **Item** | The commercial thing a business sells (dish, product, service). Not stock, not a recipe, not a price |
| **Variant** | A concrete sellable form of an item (size, colour). Every item has exactly one default variant; downstream modules always reference variants |
| **Default variant** | The variant used when an item has no visible variants; hidden in simple UX |
| **SKU** | Tenant-chosen stock-keeping code of a variant, unique per catalog |
| **Barcode** | A scannable value of a variant; several per variant, unique per catalog, never re-pointed |
| **Category** | Classification node; items have one primary category used to group the POS |
| **Modifier group / option** | A choice offered when selling an item; an option is an instruction ("no onion") or a reference to another variant ("extra cheese") |
| **Selection rule** | `minSelections`/`maxSelections` of a modifier group on one item |
| **Assortment** | Which items a business unit sells: `all` (with exclusions), `selected` (with inclusions) or `none` |
| **Hidden item** | An item one outlet does not sell although its business unit lists it (K16, C30); an outlet can only hide, never add |
| **Listed / sellable** | An active item and variant, in an active catalog, included by the assortment of the business unit that operates the outlet today and not hidden by that outlet |
| **Sellable read** | The compact, ETagged list an outlet's POS loads (C17) |
| **Admission** | The locked check that a variant is sellable at the moment a sale line is created (C20) |
| **Anchor** | The place where a permission is checked: organization-wide, business unit or outlet (`identity.effective_permission_at`) |
| **Catalog version** | Per-organization counter bumped on any catalog write; the base of the POS ETag (G2) |
