---
url: /reference/CATALOG_CASE_MATRIX.md
---
# Catalog Case Matrix

One row per behavioral case for the catalog use cases built so far, named in
[`CATALOG_USE_CASES.md`](CATALOG_USE_CASES.md). Each case names the test
that proves it, quoted verbatim as a spec file path, then the separator "›",
then the exact test title, exactly like
[`ORGANIZATION_CASE_MATRIX.md`](ORGANIZATION_CASE_MATRIX.md). A database
script is cited by a literal string it prints or records. A case with no
proving test answers `—` and is listed under [Gaps](#gaps), with the slice
that will prove it.

**Slices K2 and K3 are proven: C3, C4, C5 and C7, the items and their
default variant (migration `0076`), and C1, C2, C8, C9 and C10, the
workspace, the item list and search, the classification of items and the
categories (migration `0077`, baseline revision 87), over HTTP against
PostgreSQL 18.** Slice K5 is proven too: C12 and C13, the modifier groups,
their options and the groups an item offers (no migration, baseline
revision 88). Slice K4 is proven too: C6, the variants and the size by
colour matrix, C11, the barcodes, and C18, the barcode lookup at an outlet
and in the back office (migration `0079`, baseline revision 89). No row is
open: K8 is proven too (below) and the gap count is 0. The K3 leftovers (category ids in the item create, the
accent-insensitive search of migration `0078`, baseline revision 88) are
proven too. The hardening round is proven too (migration `0080`, baseline revision
90\): the preselected options belong to each item offer
(`defaultOptionIds`, C13) and leave every offer when the option, its group
or its target variant is retired (C12), and an organization holds at most
500 categories (C9) and 500 modifier groups (C12), archived ones included.
Slice K6 is proven too (no migration, baseline revision 90): C14, C15 and
C16, the assortment of a business unit, with the authorization anchored at
the unit, the `listed` filter of the item list, the `curate` action and the
OD5 warning of the workspace, and the till lookup following the listings. Slice K7 is proven too (no migration, baseline revision 90): C17, what
an outlet sells, with its category tree, its available modifier options and
the defaults of each offer, and C19, the paged search of what an outlet
sells, both anchored at the outlet and carrying the catalog version they were
read at. Slice K8 is proven too (no migration, baseline revision 90): C20,
the in-process `CatalogSellableContract` that Sales and Pricing will consume,
with `resolve` and the locked `admit`, the same sellable rule as the till
lists and the scan, and no row left open. The idempotent creates are proven
too (migration `0081`, baseline revision 91): the category, modifier group
and option creates take an `Idempotency-Key` like the item create, every
create replays the resource as it is now even after an edit (C4, C9, C12),
and one category is read and guarded with its own ETag (C9). The contract
step and the where-used read are proven too (migration `0082`, baseline
revision 92): `catalog.modifier_options.is_default` is gone, and
`GET /catalog/modifier-groups/{modifierGroupId}/offers` answers which items
offer a group, with their rules and defaults, keyset paged (C12). The
admission gate before Sales is measured too (2026-10-06, no migration):
catalog commands keep succeeding while tills admit, as long as a sale holds
its transaction 50 ms or less after `admit` ("K8 as built", the gate row). It
is a measurement, not a proving test, so it adds no row to the tables:
`npm run perf:admission`, results in
`docs/operations/PERFORMANCE_BASELINE.md` §15. Slice K9a is proven too
(migration `0084`, baseline revision 94): an offer can be `retired`, kept
for the references of its neighbours and ignored by every read, while a
suspended (`inactive`) required offer still hides the item (C13,
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.1); no command writes
`retired` yet, so the rows set it in SQL. Slice K9b is proven too (no
migration, baseline revision 95): the offer command keeps every offer id,
retires the groups it leaves out instead of deleting them, revives the same
id when a retired group is offered again, and keeps the rows of Pricing and
Sales that reference an offer valid (C13). Slice K9c is proven too
(migration `0091`, baseline revision 101): the runtime role holds no `DELETE`
on the offers, so an offer can only be retired, never deleted (C13). Slice K9d is proven too (no
migration, baseline revision 95): `admit` takes each line's quantity and its
selections `{ optionId, quantity }`, judges the line on the variant's sales
quantity policy, each option on its maximum and each group on the sum of its
units, and agrees with the real Sales modifier trigger and group check (C20,
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.4). Slice K9e is proven
too (no migration, baseline revision 96): `resolve` and `admit` answer the
effective offer identity of every line, and the Sales rows built from the
admitted answer alone pass the real Sales modifier trigger and group check
(C20, [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §5.1). Slice K9f is
proven too (no migration): one effective-offer rule per variant (an active
variant offer replaces the item offer, an inactive one opts the variant out,
a missing or retired one inherits) decides the scan, the till, the search,
`resolve` and `admit`, which agree on one table of cases (C17 to C20,
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.2). The F1 fix is proven too (no
migration): a group's capacity is the units its available options can reach,
the sum of their `maxQuantity`, so one Cheddar chosen up to twice can be
offered with a minimum of two and stays on sale after another option leaves,
and the offer command, the scan, the till, the search, `resolve` and `admit`
agree (C13, C17, C20, [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.2). Slice K9g-0 is proven
too (migration `0090`, baseline revision 100): the Sales modifier trigger
and group check follow the same rule, so an opted-out variant needs no
selection in Sales either (X13 closed). Slice K9g is proven
too (no migration): `PUT /catalog/items/{itemId}/variants/{variantId}/modifier-groups`
writes the overrides and opt-outs of one variant in place, the item route
suspends and resumes an offer, the item read answers both, the where-used
read names the variant of an override, and the till list and search answer
`modifierGroupOverrides` on each variant that has one (C3, C12, C13, C17,
C19, [`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §4.3); since API v7 PR 5 those overrides travel in `variantOverrides` of `PUT /catalog/items/{itemId}/modifier-groups`.
Slice B1 is proven too (migration `0100`, baseline revision 110): the
composition of a combo, kit or package, `GET` and `PUT
/catalog/items/{itemId}/bundle-groups`, with stable ids, retirement instead
of deletion, nested combos and the cycle guard (C22, [Combos](#combos)). Slice B2 is
proven too (no migration): `admit` judges and answers the components of a
combo, nested combos included (C28).
Slice M1 is proven too (migration `0101`, baseline revision 111): the
structure of a menu, `/catalog/menus` with its sections, subsections and
entries, stable ids, retirement instead of deletion, activation refused for
an empty menu or one with nothing available, and archival (C21,
[Menus](#menus)).
API v7 PR 7 is proven too (migration `0104`, baseline revision 114): one
`listing-changes` route for listing and delisting (C15), the item list by
several statuses, a category tree and a primary image with its thumbnail on
every row (C1, C2), and a category tree tag that only category writes move
(C10).
Slice M2a is proven too (migration `0105`, baseline revision 115): which
menus an outlet shows and in what order, read and replaced as one list under
a required `If-Match`, anchored at the outlet, and never limiting what it
sells (C29, [Menus](#menus)).
Slice K16 is proven too (migration `0106`, baseline revision 116): the items an outlet does not sell, hide-only over the assortment of its unit, in the till list, the search, the scan, `resolve` and `admit`, in the components of a combo and in the menus of that outlet (C30, [Outlet hidden items](#outlet-hidden-items)).
Slice S1 is proven too (migration `0107`, baseline revision 117): the hours in which an outlet shows each of its menus, written with the menus and judged by the server on the till read in the time zone of the site (C31, [Menu time windows](#menu-time-windows)).
Slice M2b is proven too (no migration, baseline revision 115): the till read
carries the active menus of the outlet, in its order, by ids only, with the
entries the same answer sells, in the same statements (C17).
Slice B2c is proven too (no migration, baseline revision 114): the till read
and the till search carry the composition of each combo, only the choices
`admit` accepts, judged by the same SQL text as the sellable rule (C17, C19).
Slice K14a is proven too (migration `0108`, baseline revision 118): a preselected
option starts with a quantity of its own, it counts against `maxSelections`, and
`reducedDefaults` names a default chosen with less (C13, C17, C20).
Slice K14b is proven too (no migration, baseline revision 118): an offer lowers
or withholds an option for itself (`optionLimits`), judged against the option
and feeding the minimum, the defaults, the till, `resolve` and `admit` (C13,
C17, C19, C20).
Slice K14c is proven too (migration `0110`, baseline revision 120): text
options, prefixes and print hints on the options of a group, judged by the
domain and backed by `CHECK`s (C12).
Slice K14d is proven too (no migration, baseline revision 120): the till read,
`resolve` and `admit` answer the presentation of an option only when it
differs from the default, and `admit` judges the text of a selection (C17,
C20).
Slice K12a is proven too (migration `0114`, baseline revision 124): every
catalog write leaves typed events with their tombstones in the outbox, in the
transaction of the write, under the lock of the catalog version (C40).
Slice K12b is proven too (no migration, baseline revision 124): an outlet is
bootstrapped for offline work in unlimited pages pinned to one cut, with the
SKU and the barcodes of what it sells, and the bootstrap plus the change feed
after its cut equals a fresh bootstrap (C41, C42).

***

## Totals

| | |
|---|---:|
| Use cases | 42 |
| Per-case scenario rows | 746 |
| Per-case scenario rows proven | 746 |
| Cross-tenant and concurrency invariants | 37 (37 proven) |
| Gaps | 0 |

***

## Item reads

### C1 — Open the catalog workspace

`GET /catalog/workspace` with an optional `businessUnitId`; needs
`catalog.items.view` organization-wide or at that business unit, never the
plan. Not tagged: it embeds the first page of the item list.

| Case | Expected answer | Proving test |
|---|---|---|
| A catalog nobody wrote yet | `200`, the default catalog that the first business unit created at provisioning (D1, revision 109; `catalog: null` remains only for an organization without one), zero counts, no categories, an empty page, `allowedActions` `edit` and `retire`; the catalog version does not move | `test/e2e/catalog-workspace.e2e-spec.ts` › `opens a catalog nobody wrote yet with the default catalog its first business unit created, zero counts, no categories and an empty page, and writes nothing` |
| Counts by status and the first page of active items | `200`; the page is the one `GET /catalog/items` answers with `limit` 50 and its cursor continues there | `test/e2e/catalog-workspace.e2e-spec.ts` › `answers the default catalog, the items counted by status and the first page of active items, whose cursor GET /catalog/items continues` |
| A manager of one unit follows `itemsPageInfo.endCursor` into `GET /catalog/items` at the same unit | `200` with the next page: the cursor binds the unit | `test/e2e/catalog-workspace.e2e-spec.ts` › `gives a manager of one business unit a first-page cursor that GET /catalog/items continues at the same unit` |
| The counts in the strict response, even when the facts carry more; no catalog and no assortment | exactly `active`, `inactive`, `archived`; `null` | `src/modules/catalog/presentation/dtos/reads/workspace-response.spec.ts` › `answers only the three counts even when the facts carry more, so the strict schema still holds`; `src/modules/catalog/presentation/dtos/reads/workspace-response.spec.ts` › `answers no catalog and no assortment as null` |
| The assortment of a unit without a row, with an active `all` or `selected` row, with an inactive row | `all`, `all`, `selected`, `none` (the default catalog is always `organization` mode) | `src/modules/catalog/infrastructure/reads/catalog-read-rows.spec.ts` › `answers the assortment of a unit with $label as $expected` |
| A tree over 500 categories (only reachable by writing rows in SQL: the creation cap is 500) | the first 500 and no truncation flag (`categoriesTruncated` was removed in OpenAPI `12.0.0`, API v7 PR 6) | `test/e2e/catalog-workspace.e2e-spec.ts` › `caps the category tree at 500 nodes, the most a catalog can hold, without a truncation flag` |
| The assortment of a named unit and the allowed actions | `assortment.mode` `all` without a row; `edit`, `retire` and `curate` (at the named unit) for an organization-wide manager, only `curate` for a manager of that unit, only `retire` on a lapsed plan; no refusal is recorded | `test/e2e/catalog-workspace.e2e-spec.ts` › `answers the assortment of a named unit and what the caller may do: everything for the owner and an organization-wide manager, only curating that unit for a manager of one unit, and only retiring on a lapsed plan`; `src/modules/catalog/application/reads/open-workspace.use-case.spec.ts` › `offers only retiring when the plan lacks the catalog, and nothing to a member without the manage permission, without recording a refusal` |
| A manager of one unit with its unit, without a unit, with another unit | `200`, `403 · catalog.action_forbidden`, `403`; an unknown unit `404 · organization.structure_not_found` only for an organization-wide reader | `test/e2e/catalog-reads-access.e2e-spec.ts` › `lets a manager of one business unit read at that unit, never without it nor at an unknown unit or a unit of another organization` |
| Cashier, waiter, kitchen, bar and a member without a role, with any unit | the same `403 · catalog.action_forbidden`, nothing recorded | `test/e2e/catalog-reads-access.e2e-spec.ts` › `answers a cashier, a waiter, a kitchen and a bar member and a member without a role the same plain denial on every read, with an existing, an unknown or no unit and with any filter, and records nothing` |
| An organization whose plan lost the catalog | `200` | `test/e2e/catalog-reads-access.e2e-spec.ts` › `keeps every read open to the owner of an organization whose plan lost the catalog` |
| The categories of the workspace, under an inactive parent | each with its own `status`, `isEffectivelyActive` and `blockedByAncestorId`, derived from the tree it read, with no other statement | `test/e2e/catalog-category-visibility.e2e-spec.ts` › `hides the active children of an inactive parent without changing their status, names the nearest blocking ancestor, and shows them again once the parent is reactivated while an inactive child stays inactive`; `src/modules/catalog/application/reads/open-workspace.use-case.spec.ts` › `answers each category with its effective visibility derived from the tree it read, with no other statement` |
| The primary images of the first page | the same `primaryImage` as the list, from one Assets call | `src/modules/catalog/application/reads/open-workspace.use-case.spec.ts` › `carries the primary image thumbnail of each item of its first page exactly as the item list does, in one call to Assets` |
| The order of the checks | view permission, allowed actions, facts, tree, first page; nothing written | `src/modules/catalog/application/reads/open-workspace.use-case.spec.ts` › `checks the view permission, then what the caller may do, then reads the facts, the tree and the first page of active items, writing nothing` |

### C2 — List and search items

`GET /catalog/items` with `q`, `status` (one or several, comma separated),
`categoryId` with `includeDescendants`, `hasImage`, `variantValue` (K13), `businessUnitId`,
`listed`, `limit`, `after` and `before`; same authorization as C1. Every row
carries `primaryImage` (API v7 PR 7). Keyset order on
the stored lowercase name (`name_search_key`, collation `C`) and the id;
prefix search on the stored lowercase name, code and SKU keys (migration
`0077`).

| Case | Expected answer | Proving test |
|---|---|---|
| Active items by lowercase name with the default variant and the variant count, forward and backward pages | `200`, `meta.pageInfo` only, `Cache-Control: private, no-cache`, no ETag | `test/e2e/catalog-item-list.e2e-spec.ts` › `lists the active items by name, ignoring case, with the default variant and the variant count, private and never tagged, and pages forward and back with keyset cursors`; `src/modules/catalog/infrastructure/reads/catalog-read.repository.integration-spec.ts` › `orders by the lowercase name and the id, counts every variant and names the default variant and the primary category` |
| Prefix on name, code and SKU; case of accented letters; a substring; `%`, `_`, backslash and quotes; an empty, blank or 81-character `q` | prefix only, case-insensitive, every character literal; `400 · foundation.validation_failed` for the malformed `q` | `test/e2e/catalog-item-list.e2e-spec.ts` › `finds items by a case-insensitive prefix of their name, code or SKU, never by a substring, and takes %, _, backslash and quotes literally`; `src/modules/catalog/infrastructure/reads/catalog-read.repository.integration-spec.ts` › `pages backward from a position and filters by a category, by status and by a literal prefix of the name, the code or any SKU` |
| Accents: `cafe`, `CAFE`, `café`, `CAFÉ`, `cafè` find `Café`, `CAFÉ helado`, `Cafè latte`; `nandu` finds `Ñandú`; `d` finds no `Ñ`; an accented SKU; `%`, `_`, backslash after the prefix; paging those names one by one | the same items in key order, nothing for the literal wildcards, no gap or duplicate across pages | `test/e2e/catalog-item-list.e2e-spec.ts` › `finds items by a prefix that ignores accents and case, in the name, the code and the SKU, and pages through accented names without a gap or a duplicate`; `src/modules/catalog/infrastructure/reads/catalog-read.repository.integration-spec.ts` › `matches a prefix of the name, the code or a SKU ignoring accents and case, and keeps %, _ and backslash literal`; `database/tests/contracts/catalog-search-key-contracts.ts` › `catalog.search_key lowercases and folds the Latin accents, and keeps every other character` |
| The statement of the list: one more row than the limit, the filters and the cursor before the limit, the same `catalog.search_key` on `q`, never `LIKE` or `OFFSET` | as described | `src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts` › `numbers the text, the category and the keyset position after the fixed parameters, and filters before the limit`; `src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts` › `reads one more row than the limit, ascending, with no filter and no keyset position`; `src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts` › `reads a backward page descending from its position` |
| Paging by a category over several pages | full pages from each cursor, no gap, no duplicate, the last page says so | `src/modules/catalog/infrastructure/reads/catalog-read.repository.integration-spec.ts` › `pages forward by a category through full pages from each cursor position, with no gap, no duplicate and the last page saying so` |
| The prefix and the keyset position under row level security | index conditions on `catalog_items_name_search_idx` over a seeded catalog | `database/tests/contracts/catalog-search-key-contracts.ts` › `is an index condition on catalog_items_name_search_idx under row level security` |
| Status filter | `active` by default; `inactive` and `archived` only when asked | `test/e2e/catalog-item-list.e2e-spec.ts` › `answers the active items by default and the inactive or archived ones only when asked` |
| Several statuses, in any order | one order by name across them, page after page; the same cursor for `inactive,active` and `active,inactive`; `400 · collection.cursor_incompatible` for another set | `test/e2e/catalog-item-list-filters.e2e-spec.ts` › `lists several statuses in one order by name, its cursor bound to the set of statuses whatever their order`; `src/modules/catalog/infrastructure/reads/item-list-filters.integration-spec.ts` › `merges several statuses into one order by name, page after page, with the next page known exactly`; `src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts` › `reads one or several statuses separated by commas in the order of the lifecycle, active when absent` |
| The statement for several statuses | one ordered branch per status with its own limit, merged and limited again, the prefix judged per branch; one status keeps its single scan | `src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts` › `reads each status of several as its own ordered branch with its own limit, merged in the same order and limited again, the text prefix matched once for all of them`; `src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts` › `keeps a single status as the one ordered scan it always was, without a union` |
| A category with its descendants | the category alone without `includeDescendants`; with it the two levels below too, each item once, nothing from a sibling tree; a cursor without it refused | `test/e2e/catalog-item-list-filters.e2e-spec.ts` › `lists the items of a category and of the categories below it only when includeDescendants is true`; `src/modules/catalog/infrastructure/reads/item-list-filters.integration-spec.ts` › `keeps the category alone, or the category and the two levels below it, each item once and nothing from a sibling tree`; `src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts` › `walks the category and at most two levels below it by the parent index when descendants are included` |
| The primary image of each row and the image filter | `primaryImage` with the thumbnail URL, `null` without a primary image of the item (a variant image does not count); `hasImage` keeps either side; one Assets call per page, none for a page without images | `test/e2e/catalog-item-list-filters.e2e-spec.ts` › `answers the primary image thumbnail of each item, null without one, filters by it, and the workspace first page carries the same rows`; `src/modules/catalog/infrastructure/reads/item-list-filters.integration-spec.ts` › `answers the primary image of the item itself and filters by it, never counting the image of a variant`; `src/modules/catalog/application/reads/list-items.use-case.spec.ts` › `describes the primary images of a page in one call for their thumbnails, and never calls Assets for a page without images`; `src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts` › `filters by the presence of the item primary image with one keyed probe, and reads the primary image of every row` |
| An unknown or repeated status, `includeDescendants` without a category, `hasImage` other than true or false | `400 · foundation.validation_failed` pointing at `status[1]`, `includeDescendants` or `hasImage` | `test/e2e/catalog-item-list-filters.e2e-spec.ts` › `refuses an unknown or repeated status, includeDescendants without a category and a hasImage that is not true or false, pointing at the parameter`; `src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts` › `points at an unknown, empty or repeated status by its position`; `src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts` › `includes the descendants of a category only when asked, and only with a category` |
| The new filters right after a bulk import, before the statistics know it, in the good and in the bad plan | no sequential scan of the large tables, no looped wide scan, no scan of `categories` repeated per row, at most 4 rows and 4 buffers per imported item (8 with a text prefix), three statuses at most three single-status pages, a one-letter prefix within the same ceiling | `src/modules/catalog/infrastructure/reads/item-list.plan.integration-spec.ts` › `reads a first page of 50 by its keys for several statuses, a category tree, an image filter and all of them at once`; `src/modules/catalog/infrastructure/reads/item-list.plan.integration-spec.ts` › `reads a category or a text prefix once whatever the number of statuses, so three statuses cost at most three single-status pages`; `src/modules/catalog/infrastructure/reads/item-list.plan.integration-spec.ts` › `finds a one-letter prefix that every imported item starts with in bounded rows and buffers` |
| The statements of a page | 7 whatever the filters, 8 with `listed` or with primary images | `test/e2e/catalog-reads-statement-budget.e2e-spec.ts` › `lists the items in 7 statements whatever the filters and 8 when the page holds primary images, opens the workspace in 12 and 13, and reads the category tree in 7` |
| Category filter and the primary category | the items classified in the category; `primaryCategory` names the primary one | `test/e2e/catalog-item-classification.e2e-spec.ts` › `classifies an item with one primary category, shows it in the item, its tag and the lists, replaces and empties the set, and records only real changes` |
| `limit=101`, an unknown status or field, a forged `organizationId`; a tampered cursor, one from another filter or organization, an expired one | `400 · foundation.validation_failed`; `400 · collection.cursor_tampered` (or `cursor_invalid`), `cursor_incompatible`, `cursor_expired` | `test/e2e/catalog-item-list.e2e-spec.ts` › `refuses a limit over 100, an unknown status or field, and tampered, foreign, cross-filter and expired cursors with their own codes` |
| A member without `catalog.items.view` with a tampered cursor; an authorized reader with the same cursor | the plain `403 · catalog.action_forbidden`; `400 · collection.cursor_tampered` (or `cursor_invalid`) | `test/e2e/catalog-reads-access.e2e-spec.ts` › `answers a member without the view permission the plain denial even with a tampered cursor, and an authorized reader the cursor failure` |
| Items created between pages | no gap, no duplicate, still ordered | `test/e2e/catalog-item-list.e2e-spec.ts` › `pages without a gap or a duplicate while items are created between the pages` |
| The same names and codes in two organizations | each lists only its own | `test/e2e/catalog-item-list.e2e-spec.ts` › `lists only the items of the caller organization even when another one holds the same names and codes` |
| A member without `catalog.items.view`, with any unit or filter | the same `403 · catalog.action_forbidden`, the unit never looked up | `test/e2e/catalog-reads-access.e2e-spec.ts` › `answers a cashier, a waiter, a kitchen and a bar member and a member without a role the same plain denial on every read, with an existing, an unknown or no unit and with any filter, and records nothing`; `src/modules/catalog/application/reads/list-items.use-case.spec.ts` › `refuses a member without the view permission plainly, for any unit, before decoding the cursor or looking anything up` |
| A manager of one unit; an unknown unit | `200` at its unit only; `404 · organization.structure_not_found` only for an organization-wide reader | `test/e2e/catalog-reads-access.e2e-spec.ts` › `lets a manager of one business unit read at that unit, never without it nor at an unknown unit or a unit of another organization`; `src/modules/catalog/application/reads/list-items.use-case.spec.ts` › `tells only an organization-wide reader that a unit does not exist` |
| A lapsed plan | `200`: reads are never gated | `test/e2e/catalog-reads-access.e2e-spec.ts` › `keeps every read open to the owner of an organization whose plan lost the catalog` |
| `variantValue=<dimension code>:<value code>` | only the items that have an active variant carrying the value, the codes compared without case; a code that does not exist lists nothing and is no error | `test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts` › `lists the items that have an active variant carrying the value, by the codes of the dimension and of the value ignoring case, and lists nothing for a code that does not exist` |
| `variantValue` repeated, up to three times, for different dimensions | one active variant must carry every value: a color of one variant and a size of another do not match; a variant that is deactivated stops matching | `test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts` › `asks one active variant for every value of several, so a color of one variant and a size of another do not match` |
| The only active variant that carries a value is deactivated, then reactivated | the item leaves the filtered list and comes back | `test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts` › `ignores the variants that are not active: the only active carrier of a value leaves, and the item with it` |
| `variantValue` with `q`, `status` | the intersection, in the usual order | `test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts` › `combines with the status and the text filters` |
| A cursor under another `variantValue`, or under none | the cursor is bound to the filter: `400 · collection.cursor_incompatible`; the same filter pages on | `test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts` › `pages with a cursor that belongs to its filter, and refuses it under another` |
| A value without its two codes, more than three values, one dimension twice | `400 · foundation.validation_failed` pointing at `variantValue` or `variantValue[n]` with a message that shows `variantValue=talla:m` and where the codes come from | `test/e2e/catalog-variant-dimensions-filter.e2e-spec.ts` › `refuses a malformed value, more than three and one dimension twice with a 400 that says how to send it` |
| The schema and the statement of the filter | one or several values normalized to lower-case codes; one more candidate with the value found by the code of its dimension and its value, the same variant joined by key for each further value | `src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts` › `takes one value or a repeated one, up to three, as lower-case codes of a dimension and of a value`; `src/modules/catalog/infrastructure/reads/item-list.queries.spec.ts` › `asks the same active variant for every value, joining its links by its key, and numbers the parameters after the others`; `src/modules/catalog/presentation/controllers/reads/item-list-cursor.spec.ts` › `changes with the variant values, so a cursor of one filter is refused by another` |
| The plan with a variant value, alone and with the other filters, among 10,000 linked items | every probe is a keyed lookup and no table is scanned whole, inside a ceiling of its own (5 rows and buffers per imported item, the other filters keep 4) | `src/modules/catalog/infrastructure/reads/item-list.plan.integration-spec.ts` › `reads a first page of 50 by its keys for a value of a variant dimension, alone, twice, among the other filters and unknown` |

### C3 — Read one item

`GET /catalog/items/{itemId}` with an optional `businessUnitId`; needs
`catalog.items.view` organization-wide or at that business unit, never the
plan. The strong ETag is a hash of the item and its variants as answered
(D24 "hash del contenido"), so two equal representations share a tag.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: the item with its variants, the same body and ETag the create answered | `200`, `Cache-Control: private, no-cache`, strong ETag | `test/e2e/catalog-items.e2e-spec.ts` › `creates an item with its active default variant and answers the same body and strong ETag the read answers` |
| `If-None-Match` with the current tag, then with a tag older than an edit | `304` with no body, then `200` with the new tag | `test/e2e/catalog-items.e2e-spec.ts` › `answers 304 to an unchanged item, and a new tag after an edit; an identical edit writes and records nothing` |
| An archived item | `200`, status `archived` | `test/e2e/catalog-items.e2e-spec.ts` › `keeps an archived item read-only until it is restored, refuses reactivating it, and answers repeats without a record`; `src/modules/catalog/application/items/read-item.use-case.spec.ts` › `reads an item, archived ones included, after the view permission, without locking or recording anything` |
| An item of another organization, or an unknown id | `404 · catalog.not_found`, the same body for both | `test/e2e/catalog-items-isolation.e2e-spec.ts` › `answers an item of another organization exactly like an unknown id on the read, the edit and every transition, and changes nothing` |
| A manager assigned to one business unit, with and without `businessUnitId`, and with a unit of another organization | `200` at its unit, `403 · catalog.action_forbidden` without it and at the foreign unit; only an organization-wide reader gets `404 · organization.structure_not_found` for the foreign unit | `test/e2e/catalog-items-isolation.e2e-spec.ts` › `lets a manager of one business unit read an item at that unit, never without it nor at a unit of another organization, and tells only an organization-wide reader that such a unit does not exist`; `src/modules/catalog/application/items/read-item.use-case.spec.ts` › `anchors the view permission at a named unit first and never looks the unit up for a granted caller` |
| A cashier or a member with no role sends an existing unit id, an unknown unit id or none | the same `403 · catalog.action_forbidden` for all three: the permission is checked before the unit is looked up, so unit ids cannot be probed | `test/e2e/catalog-items-access.e2e-spec.ts` › `answers a member without the view permission the same plain denial for an existing unit, an unknown unit and no unit, and only an authorized caller learns that a unit is unknown`; `src/modules/catalog/application/items/read-item.use-case.spec.ts` › `tells only an organization-wide reader that a refused unit does not exist, and answers everyone else the plain denial without looking the unit up` |
| Kitchen, bar, cashier and waiter, whose roles hold no `catalog.items.view` | `403 · catalog.action_forbidden` | `test/e2e/catalog-items-access.e2e-spec.ts` › `keeps the item master from members whose roles hold no catalog view: kitchen, bar, cashier and waiter` |
| The ETag changes after the item is reclassified | `categories[]` joins the representation and its hash; the old `If-None-Match` answers `200` | `test/e2e/catalog-item-classification.e2e-spec.ts` › `classifies an item with one primary category, shows it in the item, its tag and the lists, replaces and empties the set, and records only real changes` |
| The ETag changes after an offered modifier group is renamed or one of its options is edited or deactivated | `modifierGroups[]` joins the representation and its hash; the old `If-None-Match` answers `200` and the old `If-Match` `412` | `test/e2e/catalog-modifier-offers.e2e-spec.ts` › `changes the item ETag when an offered group is renamed or one of its options is edited or deactivated, so the old tag stops matching`; `test/e2e/catalog-modifiers-journey.e2e-spec.ts` › `creates the cooking point and the extras, offers them on a burger, reads the burger with its groups, sees its tag change after an option edit and a deactivation, and the old tag stop matching` |
| The ETag changes after a variant is added or the barcode set changes | `variants[].barcodes[]` joins the representation and its hash; the old `If-None-Match` answers `200` | `test/e2e/catalog-variants.e2e-spec.ts` › `adds a named variant with If-Match, answers the item with both variants and a new tag the read repeats, records it once, and refuses the same code in any case with catalog.code_taken`; `test/e2e/catalog-barcodes.e2e-spec.ts` › `sets the barcodes of a variant, answers the set with the item tag, records one record with the live set before and after, refuses the same value on another item and lets another organization use it` |
| A variant linked to dimension values by a matrix; one never linked | `variants[].dimensionValues` lists `{ dimension: { code, name }, value: { code, name } }` in the order of the dimensions of the item; the field is absent for the default variant and for any variant without links, so those payloads and tags are byte-identical to before (the rich item golden file is unchanged) | `src/modules/catalog/infrastructure/items/item-reads.spec.ts` › `carries the dimension values of a linked variant, in the order of the dimensions of the item, and nothing for a variant without links`; `src/modules/catalog/presentation/dtos/items/item-response.spec.ts` › `answers dimensionValues only for a linked variant, as the dimension and the value it holds in each dimension, in order`; `src/modules/catalog/infrastructure/items/item-read.plan.integration-spec.ts` › `reads 100 imported items that each hold two dimension values by their keys, never scanning a whole table` |
| An organization the staff suspended | `403 · tenant.context_rejected`, before any catalog question | `test/e2e/catalog-items-access.e2e-spec.ts` › `refuses every member of an organization the staff suspended at the door, before any catalog question` |
| A variant that overrides one group and opts out of another; a suspended item offer (K9g) | each variant answers its `modifierGroupOverrides` in order (`optedOut` false with its own rule, true for a drop), a variant without one an empty list; the item group answers `suspended: true`; a retired variant row never appears | `src/modules/catalog/presentation/dtos/items/item-response.spec.ts` › `answers a suspended item offer and the overrides and opt-outs of each variant`; `src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts` › `reads the live offers of each variant beside the item-level ones, an opt-out as inactive and never a retired one` |

### C10 — Read the category tree

`GET /catalog/categories` with optional `status` and `businessUnitId`; same
authorization as C1. Strong ETag `organization:category-tree:version:status`
from the category tree version (`catalog_versions.categories_version`, API
v7 PR 7): only a category write of the organization changes it.

| Case | Expected answer | Proving test |
|---|---|---|
| The whole tree depth first, siblings by order then name, each with its depth | `200`, strong ETag, `Cache-Control: private, no-cache`, `meta: {}`; `304` to the same tag | `test/e2e/catalog-category-tree.e2e-spec.ts` › `answers the whole tree depth first, siblings by sort order and then name, with the depth of each node, a strong tag and private no-cache, and 304 to that tag` |
| An organization that never wrote its catalog | `200`, no node, one stable strong tag, `304` to it strong or weak, nothing written | `test/e2e/catalog-category-tree.e2e-spec.ts` › `answers an organization that never wrote its catalog an empty tree under one stable strong tag, and 304 to that tag, strong or weak, writing nothing`; `src/modules/catalog/infrastructure/reads/catalog-read-rows.spec.ts` › `answers the version and no node for the one row of an organization without categories` |
| A status filter | only that status, a kept child still names its excluded parent, another tag; an unknown status `400` | `test/e2e/catalog-category-tree.e2e-spec.ts` › `keeps only the nodes of one status when asked, a child of an excluded parent still naming its parent, under another tag` |
| The effective visibility of each node: an inactive parent over active and inactive children, a grandchild under two inactive ancestors, the parent reactivated | own `status` unchanged; `isEffectivelyActive` true only when the node and every ancestor are active; `blockedByAncestorId` the nearest ancestor that is not active, `null` when the node itself is the only cause; after the reactivation the active children are visible again and an inactive child stays inactive | `test/e2e/catalog-category-visibility.e2e-spec.ts` › `hides the active children of an inactive parent without changing their status, names the nearest blocking ancestor, and shows them again once the parent is reactivated while an inactive child stays inactive`; `src/modules/catalog/application/reads/read-category-tree.use-case.spec.ts` › `derives the effective visibility of every node from the tree itself, with no other statement, when no status is asked`; `src/modules/catalog/domain/categories/category-visibility.spec.ts` › `derives $label`; `src/modules/catalog/domain/categories/category-visibility.spec.ts` › `derives every node of a tree in one pass whatever the order of the nodes`; `src/modules/catalog/domain/categories/category-visibility.spec.ts` › `treats a parent missing from the hierarchy as a root, and stops at a loop instead of walking forever` |
| A status filter that leaves the ancestors out | the kept nodes still name their blocking ancestor, from one more hierarchy statement | `test/e2e/catalog-category-visibility.e2e-spec.ts` › `hides the active children of an inactive parent without changing their status, names the nearest blocking ancestor, and shows them again once the parent is reactivated while an inactive child stays inactive`; `src/modules/catalog/application/reads/read-category-tree.use-case.spec.ts` › `reads the whole hierarchy once to name the blocking ancestor of a node whose ancestors the status filter left out` |
| A move of the tree committed between the filtered tree and its hierarchy | the nodes, depths, visibility and tag come from one read-only snapshot: the tree before the move or the tree after it, never the nodes of one with the blocking ancestors of the other | `test/e2e/catalog-category-read-race.e2e-spec.ts` › `answers the nodes, the depths and the blocking ancestors of one version of the tree when the move commits between the filtered tree and its hierarchy` |
| An item write, a classification, a category write, a write of another organization | the tag stays for item writes and classifications; it changes for a category write; never for another organization; the old tag stops answering `304` | `test/e2e/catalog-category-tree.e2e-spec.ts` › `keeps the tag while only items change, classifications included, changes it on a category write of the organization, never on a write of another organization, and an old tag stops answering 304` |
| The tree version in the database | one step per category statement, none for an update that leaves the shown columns as they were, none for item writes, guarded against forging | `database/tests/contracts/catalog-category-tree-version-contracts.ts` › `an UPDATE that leaves every column the tree shows as it was moves only the catalog version`; `database/tests/contracts/catalog-category-tree-version-contracts.ts` › `an item and its default variant move the catalog twice and never the tree`; `database/tests/contracts/catalog-category-tree-version-contracts.ts` › `categories bump both versions once per INSERT, UPDATE and DELETE statement through one function` |
| A category rename | a new tag, the old one answers `200` | `test/e2e/catalog-categories-journey.e2e-spec.ts` › `builds three categories, creates and classifies dishes, lists them by category and by text, reads the tree with its tag, renames a category and sees the tag change and the old one stop matching` |
| A member without `catalog.items.view`, a manager of one unit, a lapsed plan | as C1 | `test/e2e/catalog-reads-access.e2e-spec.ts` › `answers a cashier, a waiter, a kitchen and a bar member and a member without a role the same plain denial on every read, with an existing, an unknown or no unit and with any filter, and records nothing`; `test/e2e/catalog-reads-access.e2e-spec.ts` › `lets a manager of one business unit read at that unit, never without it nor at an unknown unit or a unit of another organization`; `test/e2e/catalog-reads-access.e2e-spec.ts` › `keeps every read open to the owner of an organization whose plan lost the catalog` |

## Item commands

### C4 — Create an item

`POST /catalog/items` with a required `Idempotency-Key`; needs
`catalog.items.manage` organization-wide and the capability
`catalog.items`. The item and its default variant are written in one
transaction after the catalog version row is locked.

| Case | Expected answer | Proving test |
|---|---|---|
| Happy path: a simple item, its default variant `default`, a code generated from the name | `201`, the C3 shape, its ETag and `Cache-Control: private, no-store`, one `catalog.item.created` record | `test/e2e/catalog-items.e2e-spec.ts` › `creates an item with its active default variant and answers the same body and strong ETag the read answers`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `locks the catalog version before any write, generates the code from the name, writes the item and its default variant and records the creation`; `src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts` › `stores an item with its default variant in the default catalog and reads quantities back in their shortest form` |
| The generated code is taken, or the client sends one | `-2`, `-3` on collision; an explicit code is kept as sent | `src/modules/catalog/domain/items/item-code.spec.ts` › `numbers from 2 and takes the lowest free suffix, ignoring case`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `takes the next free suffix when the generated code is taken and keeps an explicit code as sent`; `test/e2e/catalog-items-isolation.e2e-spec.ts` › `lets two organizations use the same code while one organization refuses it twice, in any case` |
| A code already taken in the catalog of the organization, differing only in case | `409 · catalog.code_taken`, no constraint name in the body | `test/e2e/catalog-items-isolation.e2e-spec.ts` › `lets two organizations use the same code while one organization refuses it twice, in any case`; `src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts` › `refuses a code taken in any case, a taken SKU, an unknown unit and an impossible quantity policy with their own failures` |
| A taken SKU, an unknown sales unit, a zero step or a maximum below the minimum | `409 · catalog.sku_taken`, `422 · catalog.unit_unknown`, `422 · catalog.quantity_policy_invalid` | `test/e2e/catalog-items.e2e-spec.ts` › `refuses a taken SKU, an unknown sales unit and an impossible quantity policy with their own codes and stores nothing, its audit record included` |
| The audit record of a failed create | rolls back with it: no `catalog.item.created` record survives | `test/e2e/catalog-items.e2e-spec.ts` › `refuses a taken SKU, an unknown sales unit and an impossible quantity policy with their own codes and stores nothing, its audit record included` |
| No `Idempotency-Key`; the same key and request again; the same key with another request | `400 · foundation.validation_failed`; `201` with `Idempotent-Replayed: true` and the same body; `409 · idempotency.key_reused` | `test/e2e/catalog-items.e2e-spec.ts` › `requires an Idempotency-Key, replays the same key and request, and refuses the same key with another request`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `replays the same key and the same request with the stored item and writes or records nothing`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `refuses the same key with another request and writes nothing more` |
| A retry with the same key and body before and after the item was edited; the same key with the item's new name | before: `201` with the first body and `Idempotent-Replayed: true`; after a rename: `201`, `Idempotent-Replayed: true` and the item as it is now, because the replay compares the SHA-256 of the original request stored in `creation_request_hash` (revision 91); `409 · idempotency.key_reused` | `test/e2e/catalog-items.e2e-spec.ts` › `replays the first answer for a retry of an unedited item and the item as it is now once it was edited, because the replay compares the hash of the original request`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `replays the same key and request after the item was renamed, because the replay compares the hash of the original request, not the current item` |
| Two concurrent creates with the same explicit code | one `201`, one `409 · catalog.code_taken`, one item | `test/e2e/catalog-items-concurrency.e2e-spec.ts` › `lets exactly one of two parallel creates with the same explicit code win and refuses the other with catalog.code_taken` |
| Parallel creates that generate the same code | every one `201`, codes `base`, `base-2`, `base-3`…, no deadlock | `test/e2e/catalog-items-concurrency.e2e-spec.ts` › `gives parallel creates of the same name the base code and the next suffixes, with no conflict, no deadlock and no 500`; `database/tests/concurrency/catalog-item-concurrency.ts` › `NINAKU_CATALOG_ITEM_CONCURRENCY_OK: two creates that generate the same code wait on the catalog version row, and the second takes the next free suffix instead of colliding` |
| Parallel retries of one `Idempotency-Key` | one item, one audit record, every other answer replayed | `test/e2e/catalog-items-concurrency.e2e-spec.ts` › `creates one item for parallel retries of one Idempotency-Key and replays it to every other caller` |
| An item whose key was stored before revision 91, without a request hash | compared with the item as it is now, as before: `201` replayed while unedited, `409 · idempotency.key_reused` once edited | `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `compares an item whose key was stored before the request hash existed with the item as it is now`; `src/foundation/idempotency/resolve-idempotent-creation.spec.ts` › `compares a stored creation without a hash with the current row, the compatibility path for rows created before the hash existed` |
| The same request with the categories in another order, or quantities written `1.0`; a request that differs by one character, by type or by an explicit null | `201` replayed (the hash is taken over the canonical request: keys sorted, categories sorted by id, quantities in their shortest form); `409 · idempotency.key_reused` | `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `replays the same categories sent in another order, because the classification is a set`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `replays the same key and the same request with the stored item and writes or records nothing`; `src/foundation/idempotency/request-fingerprint.spec.ts` › `ignores the order of the keys at every depth`; `src/foundation/idempotency/request-fingerprint.spec.ts` › `tells apart values that differ only in type or in one character`; `src/foundation/idempotency/request-fingerprint.spec.ts` › `omits absent fields but keeps a null, so an omitted field and an explicit null differ`; `src/foundation/idempotency/resolve-idempotent-creation.spec.ts` › `replays the stored resource as it is now when the stored hash equals the request hash, without creating`; `src/foundation/idempotency/resolve-idempotent-creation.spec.ts` › `refuses the key with another request when the stored hash differs, whatever the current row looks like` |
| A request holding a `Date`, a `Map`, a `Set`, `NaN`, an infinite number or `undefined` inside an array reaches the fingerprint; nested arrays | refused with a `TypeError` instead of being hashed as something else; the order of nested arrays counts | `src/foundation/idempotency/request-fingerprint.spec.ts` › `refuses $label instead of hashing it as something else`; `src/foundation/idempotency/request-fingerprint.spec.ts` › `keeps the order of nested arrays at every depth` |
| A malformed hash (upper case, 63 characters, not hexadecimal) or a hash without a key written in SQL; a key with a well-formed hash; a key without a hash | refused by `<table>_creation_request_hash_chk` on the four tables; accepted; accepted (items created before revision 91) | `database/tests/contracts/catalog-creation-request-contracts.ts` › `NINAKU_CATALOG_CREATION_REQUEST_OK` |
| A retry after the member lost the permission or the plan lost the catalog, on every create | the plain `403 · catalog.action_forbidden` or `403 · platform.capability_not_entitled`, never the replay, nothing of the first answer | `test/e2e/catalog-idempotent-creates-access.e2e-spec.ts` › `answers a retry of every catalog create by a member who lost the permission with the plain denial, never the replay`; `test/e2e/catalog-idempotent-creates-access.e2e-spec.ts` › `answers a retry of every catalog create of an organization whose plan lost the catalog with the entitlement code, never the replay` |
| The documented `201` of the four creates | `Cache-Control: private, no-store` and `Idempotent-Replayed: true` (only on a replay) in OpenAPI; the `ETag` stays in the operation text | `src/foundation/openapi/api-tagged-create-headers.spec.ts` › `documents the private, no-store Cache-Control and the Idempotent-Replayed header of a 201 that a replay answers, and no ETag header, which would make the semantic check read the create as a conditional read` |
| A stored creation without a hash on a table that never had keys before revision 91 | `409 · idempotency.key_reused`: no comparator, never a replay | `src/foundation/idempotency/resolve-idempotent-creation.spec.ts` › `refuses a stored creation without a hash as another request when the caller has no rows from before the hash existed` |
| A cashier, a waiter, a kitchen member, a manager assigned to one business unit | `403 · catalog.action_forbidden`, one denied record, nothing written, nothing about the plan | `test/e2e/catalog-items-access.e2e-spec.ts` › `refuses a cashier, a waiter, a kitchen member and a manager of one business unit every item command with the plain denial, one denied record each and nothing written` |
| The owner of an organization whose plan lacks `catalog.items` | `403 · platform.capability_not_entitled`, audited as denied | `test/e2e/catalog-items-access.e2e-spec.ts` › `refuses the owner of an organization whose plan lacks the catalog to create or edit with the entitlement code, but still lets it read, deactivate and archive` |
| A NUL byte, a blank name, a forged organization, status or variant list | `400 · foundation.validation_failed`, nothing written | `src/modules/catalog/presentation/dtos/items/create-item-request.schema.spec.ts` › `refuses %s`; `test/e2e/catalog-items-isolation.e2e-spec.ts` › `refuses a forged organization, status or variant list in the body as unknown fields and writes nothing` |
| An item with three sizes | the create carries no named variants (an unknown `variants` field answers `400`); the sizes are added with `POST .../variants` or `POST .../variant-matrix` | `test/e2e/catalog-variant-matrix.e2e-spec.ts` › `creates the six variants of three sizes by two colours in one command with generated codes, names and SKUs, records it once, and answers the same matrix again with 200, the same item and tag and nothing written` |
| Classified in the create with `categoryIds` and `primaryCategoryId`; the replay; the same key with another primary | `201` with `categories[]`, primary first, in the transaction and the record of the creation; `201` replayed; `409 · idempotency.key_reused` | `test/e2e/catalog-item-classification.e2e-spec.ts` › `creates an item already classified, its primary category first, in the transaction and the record of the creation, and replays the same key and categories`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `locks the requested categories in id order after the item and its default variant, links them with the primary one and records them with the creation`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `refuses the same key with other categories, because the replay compares the classification too` |
| A category archived, unknown or foreign in the create; a repeated id, 21 ids, a primary outside them or without them | `422 · catalog.category_archived`, `404 · catalog.not_found`, `400`; nothing created or recorded | `test/e2e/catalog-item-classification.e2e-spec.ts` › `refuses to create an item in a category that is archived, unknown or foreign, a repeated id, more than twenty ids or a primary outside them, creating nothing`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `refuses to create an item in a category that is archived or does not exist, before linking or recording anything`; `src/modules/catalog/presentation/dtos/items/create-item-request.schema.spec.ts` › `links the category ids sent, marking only the primary one, and links none without them` |
| An item created with regulatory flags, and one without (K15a) | `201`; the flags answer once each in the order of the vocabulary and the key is absent on an item without them; the same request again replays, another set under the same key `409` | `test/e2e/catalog-regulatory-flags.e2e-spec.ts` › `creates an item with flags, answers them in the order of the vocabulary and answers none for an item without them`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `writes the regulatory flags after the categories, records them in the creation and writes nothing for an item without flags`; `src/modules/catalog/application/items/item-creation-intent.spec.ts` › `fingerprints the regulatory flags in the order of the vocabulary, and a draft without them as before` |
| An item created whose default variant holds a net content (K15a) | `201` with `netContentQuantity` in its shortest form and `netContentUnitCode`; the same request again replays, a different net content under the same key `409` | `test/e2e/catalog-net-content.e2e-spec.ts` › `creates an item whose default variant holds a net content, reads it back in its shortest form and replays the same request without writing again`; `src/modules/catalog/application/items/item-creation-intent.spec.ts` › `matches a stored item whose default variant holds the same net content and no other` |

### C5 — Update an item's profile

`PATCH /catalog/items/{itemId}` with any of `code`, `name`, `description`
and an optional `If-Match`; gated like C4. The catalog version row is
locked first, then the item row.

| Case | Expected answer | Proving test |
|---|---|---|
| Rename, set and clear the description | `200` with the new ETag; one `catalog.item.updated` record with each change before and after | `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `locks the catalog version before the item, writes the real changes and records them with before and after`; `test/e2e/catalog-items.e2e-spec.ts` › `answers 304 to an unchanged item, and a new tag after an edit; an identical edit writes and records nothing` |
| The values already stored | `200`, nothing written or audited | `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `answers the stored item without a write or an audit record when nothing really changes` |
| A code taken by another item | `409 · catalog.code_taken` | `src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts` › `refuses renaming the code onto a code another item holds` |
| A stale `If-Match` | `412 · foundation.precondition_failed`, nothing written | `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `refuses a stale If-Match before writing anything`; `test/e2e/catalog-items.e2e-spec.ts` › `answers 304 to an unchanged item, and a new tag after an edit; an identical edit writes and records nothing` |
| Two concurrent edits with the same `If-Match` | one `200`, one `412`, one audit record | `test/e2e/catalog-items-concurrency.e2e-spec.ts` › `accepts one of two edits sent with the same If-Match and refuses the other with 412, writing it nowhere` |
| An archived item | `422 · catalog.item_archived` until it is restored | `test/e2e/catalog-items.e2e-spec.ts` › `keeps an archived item read-only until it is restored, refuses reactivating it, and answers repeats without a record` |
| An empty body, a null name or code, the status, the organization, the default variant as an object, a `formerDefaultName` without `defaultVariantId`, a `defaultVariantId` that is not a UUID | `400 · foundation.validation_failed` | `src/modules/catalog/presentation/dtos/items/update-item-request.schema.spec.ts` › `refuses %s` |
| A plan without `catalog.items` | `403 · platform.capability_not_entitled` | `test/e2e/catalog-items-access.e2e-spec.ts` › `refuses the owner of an organization whose plan lacks the catalog to create or edit with the entitlement code, but still lets it read, deactivate and archive` |
| An item of another organization | `404 · catalog.not_found` | `test/e2e/catalog-items-isolation.e2e-spec.ts` › `answers an item of another organization exactly like an unknown id on the read, the edit and every transition, and changes nothing` |
| The UPDATE statements | never name `item_id`, `organization_id`, `catalog_id` or `created_at` in their `SET` list | `src/modules/catalog/infrastructure/items/item.queries.spec.ts` › `%s never names an identity column in its SET list` |
| The regulatory flags replaced, sent again in another order, and cleared with an empty list (K15a) | `200`; the entity tag moves with the set, the same set in another order is already in effect (same tag, nothing recorded), an empty list answers no key and the first tag; each real change is one `catalog.item.updated` record with `catalog.item.regulatory_flags` before and after | `test/e2e/catalog-regulatory-flags.e2e-spec.ts` › `replaces the flags with an edit, moves the entity tag, writes and records nothing for the same set, and clears them with an empty list`; `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `replaces the regulatory flags under the item lock and records them as one change of the set, in the order of the vocabulary` |
| A profile edit and a flag edit in one request | one write of each under the item lock and one audit record holding both changes | `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `records the profile and the flags of one edit in a single record` |
| A flag outside the five, a flag twice, `null` instead of a list | `400 · foundation.validation_failed` at `regulatoryFlags[n]` | `test/e2e/catalog-regulatory-flags.e2e-spec.ts` › `refuses an unknown flag, a repeated one and null with 400 at the field`; `src/modules/catalog/presentation/dtos/items/update-item-request.schema.spec.ts` › `takes the regulatory flags apart from the profile, in the order of the vocabulary, and an empty list to clear them` |
| Two edits of the flags sent at the same version; another organization's edit; a flagged item on the till | one `200` and one `412`; `404 · catalog.not_found`; the flagged item is still sold (Catalog declares and enforces nothing) | `test/e2e/catalog-regulatory-flags.e2e-spec.ts` › `lets one of two edits of the flags at the same version win and answers the other 412`; `test/e2e/catalog-regulatory-flags.e2e-spec.ts` › `never lets another organization edit the flags of an item and declares without enforcing: a flagged item is still sold` |

### C6 — Manage variants

`POST /catalog/items/{itemId}/variants`, `PATCH .../variants/{variantId}`,
`PATCH /catalog/items/{itemId}` with `defaultVariantId` (API v7 PR 2: it
replaced `POST .../variants/{variantId}/default-selection`),
`POST .../variants/{variantId}/transitions` with `{ transition }` (API v7
PR 2: it replaced the per-verb routes and added `restore`) and
`POST /catalog/items/{itemId}/variant-matrix`; `catalog.items.manage`
organization-wide, gated by `catalog.items` except `deactivate` and
`archive`. Every command locks the catalog version row, then the item row,
and answers the whole item with its strong ETag, except the transitions
route, which answers `{ variantId, status }` with the item ETag.

| Case | Expected answer | Proving test |
|---|---|---|
| Add a named variant with `If-Match`; the same code in any case; a stale tag | `201`, `Cache-Control: private, no-store`, new tag, one `catalog.item.variant_added`; `409 · catalog.code_taken`; `412 · foundation.precondition_failed` | `test/e2e/catalog-variants.e2e-spec.ts` › `adds a named variant with If-Match, answers the item with both variants and a new tag the read repeats, records it once, and refuses the same code in any case with catalog.code_taken`; `src/modules/catalog/application/items/manage-item-variants.use-case.spec.ts` › `adds a variant after the permission and the plan, locking the catalog version before the item, then writes and records it` |
| Edit a variant; the same values again; a SKU of another item; an unknown unit; an impossible policy; clear SKU and maximum | `200` with one `catalog.item.variant_updated` per real change; `409 · catalog.sku_taken`; `422 · catalog.unit_unknown`; `422 · catalog.quantity_policy_invalid` | `test/e2e/catalog-variants.e2e-spec.ts` › `edits a variant, writes and records only real changes, answers an identical edit without a record, and refuses a SKU another item holds, an unknown unit and an impossible quantity policy`; `src/modules/catalog/domain/items/item-variant.spec.ts` › `reports only the fields that really change, comparing quantities by their shortest decimal form` |
| Make another variant the default; the former default has no name; a repeat | exactly one default; `422 · catalog.variant_name_required` without `formerDefaultName`; a repeat answers `200` and records nothing | `test/e2e/catalog-variants.e2e-spec.ts` › `makes another variant the default, naming the former default that had no name, keeps exactly one default, records it once and answers a repeat without a record`; `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `makes another active variant the default, naming the former one, in one locked command with its own record`; `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `answers the default already selected without a write or a record` |
| Rename the item and choose its default in one `PATCH`; a variant of another item; the former `default-selection` route | `200`, one `catalog.item.updated` then one `catalog.item.default_variant_changed`; `404 · catalog.not_found`; the old route `404` | `test/e2e/catalog-variants.e2e-spec.ts` › `renames an item and chooses its default in one PATCH, one record each, and no longer answers the former default-selection route`; `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `edits the profile and the default in the same transaction, one record each`; `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `refuses a variant that is not active, a former default left without a name and a variant of another item, writing nothing`; `src/modules/catalog/application/items/update-item.use-case.spec.ts` › `audits a refused default selection under the action it asked for` |
| A `PATCH` with a new name and an inactive `defaultVariantId` | `422 · catalog.variant_not_active`; one transaction, so the name is not kept, the ETag is unchanged and no `catalog.item.updated` record is written | `test/e2e/catalog-variants.e2e-spec.ts` › `rolls back the whole PATCH when its default variant is refused: the new name is not kept and no profile record is written` |
| Deactivate or archive the default variant; make an inactive or archived variant the default | `422 · catalog.default_variant_required`; `422 · catalog.variant_not_active`; the former default can be archived once another is the default | `test/e2e/catalog-variants.e2e-spec.ts` › `never retires the default variant, never makes a variant that is not active the default, and retires the former default once another one is the default`; `src/modules/catalog/application/items/manage-item-variants.use-case.spec.ts` › `never retires the default variant and writes nothing when asked to` |
| Lifecycle of a named variant, restore included; repeats | `200` with `{ variantId, status }` each; `reactivate` of an archived one `422 · catalog.transition_invalid` whose detail names the status and `restore`, an edit `422 · catalog.variant_archived`; `restore` brings it back `inactive` with its code and SKU, editable again; `restore` of an active one `422`; repeats record nothing; the item status never moves | `test/e2e/catalog-variants.e2e-spec.ts` › `deactivates, reactivates, archives and restores a named variant without touching the item; an archived variant refuses edits and reactivation until it is restored, and repeats record nothing` |
| Restore an archived variant; restore a live one, the default included | locks the version then the item, gated by the plan, `catalog.item.variant_restored` (seeded by revision 112; `catalog.item.variant_updated` before it) with the status `archived → inactive`, no offer default touched; `422 · catalog.transition_invalid` naming `active` and `deactivate or archive`, never `catalog.default_variant_required` | `src/modules/catalog/application/items/manage-item-variants.use-case.spec.ts` › `restores an archived variant to inactive after the plan, locking the catalog version before the item, and records its status before and after`; `src/modules/catalog/application/items/manage-item-variants.use-case.spec.ts` › `refuses to restore a live variant, the default one included, naming its status and the transitions it allows`; `src/modules/catalog/domain/items/item-variant.spec.ts` › `refuses retiring the default variant, never the others, and never on a reactivation or a restore` |
| Any variant command on an archived item | `422 · catalog.item_archived` until the item is restored | `test/e2e/catalog-variants.e2e-spec.ts` › `keeps the variants of an archived item read-only until the item is restored` |
| A size by colour matrix; the same matrix again | `201` with six variants `m-azul` / `M / Azul` / `CAM-OX-M-AZUL`, one record; again: already in effect, `200` with the same body and tag, no `Idempotent-Replayed` header, nothing written | `test/e2e/catalog-variant-matrix.e2e-spec.ts` › `creates the six variants of three sizes by two colours in one command with generated codes, names and SKUs, records it once, and answers the same matrix again with 200, the same item and tag and nothing written`; `src/modules/catalog/domain/items/variant-matrix.spec.ts` › `crosses the dimensions in the order sent, the first one outermost, joining codes with a hyphen and names with a slash` |
| The first matrix of an item: its dimensions, values, links and audit record | `201`; each dimension and value that does not exist in the catalog is created, one that exists is reused by its code ignoring case; every created variant answers `dimensionValues` in the order of the dimensions; one `catalog.item.variant_added` names the dimensions (`catalog.item.variant_dimensions`) and the counts (`catalog.item.variants_created`, `catalog.item.variants_linked`) | `test/e2e/catalog-variant-dimensions.e2e-spec.ts` › `persists the dimensions, the values and the links of a matrix, answers each variant with its values in the order of the dimensions, and records the dimensions and the counts` |
| Two items share a dimension, whatever the case, accents or punctuation of its name | the dimension is created once; a value that exists keeps its stored code and name, a new one is appended | `test/e2e/catalog-variant-dimensions.e2e-spec.ts` › `shares a dimension and its values between items of the catalog, ignoring the case and the accents of its name, and never creates one twice` |
| The owner applies again a matrix whose variants exist from before dimensions | `200` when only links are written: the variants keep their ids, barcodes and images and gain their values; missing cells are created (`201`); the same matrix again is already in effect | `test/e2e/catalog-variant-dimensions.e2e-spec.ts` › `links the variants a matrix created before dimensions existed when the owner applies it again, touching neither their ids, barcodes nor images, and creates only the missing cells` |
| A cell whose code exists with another name, or with other values than the cell asks | `409 · catalog.code_taken`, nothing linked | `test/e2e/catalog-variant-dimensions.e2e-spec.ts` › `keeps refusing a cell whose code exists with another name, or with other values than the cell asks` |
| A later matrix with the same dimensions in the same order, and with any other set | the same dimensions may add values (`201`); any other set, order or count `422 · catalog.variant_dimensions_locked` whose detail names the dimensions of the item and what to send | `test/e2e/catalog-variant-dimensions.e2e-spec.ts` › `fixes the dimensions of an item with its first matrix: the same dimensions in the same order may add values, any other set is refused with 422 and what to send` |
| A matrix whose new variant would repeat the combination of an active variant; reactivating a variant that would repeat one | `409 · catalog.variant_combination_taken` whose detail says to deactivate or archive the other variant, nothing written; the database refuses the same state at commit | `test/e2e/catalog-variant-dimensions.e2e-spec.ts` › `refuses a matrix that would give a new variant the combination another active variant holds, and the reactivation of a variant that would repeat one, writing nothing` |
| Two matrices of one item sent at once | the second waits for the version row and sees what the first committed: the same matrix answers `200` and `201`, a matrix of other dimensions answers `422 · catalog.variant_dimensions_locked` | `test/e2e/catalog-variant-dimensions.e2e-spec.ts` › `orders two matrices of the same item sent at once: the second sees what the first committed` |
| Two dimension names of one matrix that reduce to the same code | `400 · foundation.validation_failed` pointing at `dimensions[n].name` | `src/modules/catalog/presentation/dtos/items/variant-matrix-request.schema.spec.ts` › `points at each dimension whose name reduces to the code of an earlier one, ignoring case, accents and punctuation` |
| The statements of a matrix | 18 for the first matrix of an item, 16 for a matrix that adds values and 16 for one that only links, for 2 variants as for 100 | `test/e2e/catalog-variant-matrix-statement-budget.e2e-spec.ts` › `takes the first matrix of an item in the same number of statements for 2 variants as for 100, creating dimensions, values, variants and links one statement each`; `test/e2e/catalog-variant-matrix-statement-budget.e2e-spec.ts` › `takes a later matrix that adds values in the same number of statements for 1 new variant as for 90`; `test/e2e/catalog-variant-matrix-statement-budget.e2e-spec.ts` › `links 2 old variants and 40 old variants in the same number of statements` |
| A matrix that collides with a variant, takes a SKU, or passes 500 variants | `409 · catalog.code_taken`, `409 · catalog.sku_taken`, `422 · catalog.item_variants_full`, nothing written | `test/e2e/catalog-variant-matrix.e2e-spec.ts` › `refuses the whole matrix when a cell exists with other values or a SKU is taken elsewhere, and when the item would pass 500 variants, writing nothing` |
| A matrix whose combination exists only as an archived variant | `409 · catalog.code_taken`, nothing created: an archived variant is never the same matrix | `test/e2e/catalog-variant-matrix.e2e-spec.ts` › `treats an archived variant with the code of a combination as a clash, never as the same matrix, and creates nothing` |
| More than 100 cells, more than 3 dimensions, a repeated value code, a malformed code, an unknown field, no dimension | `400 · foundation.validation_failed` | `test/e2e/catalog-variant-matrix.e2e-spec.ts` › `refuses more than one hundred cells, more than three dimensions, a repeated value code, a code that cannot name a variant and an unknown field with 400` |
| A cashier, a waiter, kitchen, bar and a manager of one unit, on existing and unknown ids | the same `403 · catalog.action_forbidden`, one denied record each, nothing written, the item tag unchanged | `test/e2e/catalog-variants-access.e2e-spec.ts` › `refuses a cashier, a waiter, a kitchen and a bar member and a manager of one business unit every variant, barcode and matrix command with the same plain denial for existing and unknown ids, one denied record each and nothing written` |
| A lapsed plan | add, edit, default selection, matrix, `reactivate` and `restore` `403 · platform.capability_not_entitled`; `deactivate` and `archive` `200` | `test/e2e/catalog-variants-access.e2e-spec.ts` › `refuses the owner whose plan lacks the catalog to add or edit variants and barcodes, select a default, apply a matrix, reactivate and restore with the entitlement code, while it still reads, finds barcodes, deactivates and archives` |
| An item or variant of another organization, a variant of another item, a forged `organizationId` | `404 · catalog.not_found` like an unknown id, nothing changed; `400` for the forged field | `test/e2e/catalog-variants-access.e2e-spec.ts` › `answers an item, a variant and a barcode of another organization, and a variant or barcode of another item of the same organization, exactly like unknown ids, and changes nothing` |
| The variant `UPDATE` statements | never name `item_id`, `organization_id`, `catalog_id`, `created_at`, `item_variant_id` or the barcode value in a `SET` list | `src/modules/catalog/infrastructure/items/item-variant.queries.spec.ts` › `%s never names an identity, namespace, parent or barcode value column in its SET list` |
| A restaurant adds a second portion size | `201`; the unnamed default stays the usual portion | `test/e2e/catalog-variants-journey.e2e-spec.ts` › `a restaurant owner adds a second portion size to a dish, which keeps its unnamed default for the usual portion` |
| A variant added with a net content, changed, sent again with the same value and cleared (K15a) | `201` then `200`; the answers carry the pair only while it is set; the same value is already in effect (same tag); the audit holds the added, updated and updated records | `test/e2e/catalog-net-content.e2e-spec.ts` › `adds a variant with a net content, changes it, clears it, and records only the real changes`; `src/modules/catalog/domain/items/item-variant.spec.ts` › `reports the net content as two fields in the shortest decimal form and clears both with null` |
| A net content unit the reference data does not know; the quantity without the unit or the unit without the quantity; a quantity of zero | `422 · catalog.unit_unknown`, nothing written; `400 · foundation.validation_failed` at the missing field or the quantity | `test/e2e/catalog-net-content.e2e-spec.ts` › `refuses a unit the reference data does not know with 422, writing nothing`; `test/e2e/catalog-net-content.e2e-spec.ts` › `asks for the quantity and the unit together, and greater than zero, with 400 at the field`; `src/modules/catalog/presentation/dtos/items/variant-request.schemas.spec.ts` › `changes or clears the net content of a variant, always the two fields together` |
| A minimum or a maximum sales quantity that is not a multiple of the step, on create and on edit | `422 · catalog.quantity_policy_invalid`; a policy on the step is accepted | `test/e2e/catalog-net-content.e2e-spec.ts` › `refuses a minimum or a maximum that is not a multiple of the step with 422 catalog.quantity_policy_invalid, on create and on update`; `src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts` › `refuses a net content unit the reference data does not know, a policy off its step and a net content with one half` |
| A variant that never had a net content | answers none of the two keys, byte-identical to before; the database holds no half of a net content | `test/e2e/catalog-net-content.e2e-spec.ts` › `answers a variant that never had a net content with none of its keys, and the database holds no half of it`; `src/modules/catalog/presentation/dtos/items/item-response.spec.ts` › `answers the net content of a variant only when it has one, and parses it as the wire contract` |
| The database holds the bounds and the flags table (K15a) | a description over 2000 characters, a minimum or maximum off the step, a half of a net content and a sixth flag are refused; the flags table is tenant scoped, moves the catalog version and is granted `SELECT, INSERT, DELETE` only | `database/tests/contracts/catalog-master-data-hardening-contracts.ts` › `accepts a description of 2000 characters and refuses one of 2001`; `database/tests/contracts/catalog-master-data-hardening-contracts.ts` › `a sixth flag is refused`; `database/tests/contracts/catalog-master-data-hardening-contracts.ts` › `a maximum off the step is refused` |

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

`POST /catalog/items/{itemId}/transitions` with `{ "transition":
"deactivate" | "reactivate" | "archive" | "restore" }` (API v7 PR 2: it
replaced the four per-verb routes). `deactivate` and `archive` are never
gated by the plan; `reactivate` and `restore` are (§7.3).

| Case | Expected answer | Proving test |
|---|---|---|
| Each legal transition | `200` with the item: `active → inactive → archived → inactive → active`, one record per change | `src/modules/catalog/domain/items/item-lifecycle.spec.ts` › `%s of an item that is %s is %s and leaves it %s`; `test/e2e/catalog-items-journey.e2e-spec.ts` › `creates a dish without a code, reads it with its ETag, renames it with If-Match, deactivates, archives, restores and reactivates it, and leaves one audit record per real change` |
| Each illegal transition (reactivating an archived item, restoring an active one) | `422 · catalog.transition_invalid` | `test/e2e/catalog-items.e2e-spec.ts` › `keeps an archived item read-only until it is restored, refuses reactivating it, and answers repeats without a record`; `src/modules/catalog/application/items/change-item-status.use-case.spec.ts` › `refuses an illegal transition such as reactivating an archived item` |
| The answer of a transition; a stale `If-Match`; a transition outside the closed list or another field; the former per-verb route | `200` with exactly `{ itemId, status }` and the ETag a read answers; `412 · foundation.precondition_failed`; `400 · foundation.validation_failed`; `404` | `test/e2e/catalog-items.e2e-spec.ts` › `answers a transition leanly with the id, the status and the strong ETag of the whole item, and refuses a body outside the closed list of transitions`; `src/modules/catalog/presentation/dtos/shared/transition-request.schema.spec.ts` › `refuses %j`; `src/modules/catalog/presentation/dtos/shared/transition-request.schema.spec.ts` › `accepts %s and takes the %s step` |
| A refused transition teaches | the detail names the current status and the transitions it allows, from the lifecycle table | `src/modules/catalog/presentation/failures/to-public-catalog-failure.spec.ts` › `teaches where a requested transition stands and which transitions it allows`; `src/modules/catalog/application/items/admit-item-transition.spec.ts` › `refuses with the current status and the transitions it allows`; `src/modules/catalog/domain/items/item-lifecycle.spec.ts` › `allows from %s exactly %j` |
| A transition already in effect | `200`, nothing written or audited | `src/modules/catalog/application/items/change-item-status.use-case.spec.ts` › `answers a transition already in effect with the stored item and writes or records nothing` |
| Restoration | back to `inactive`, never straight to `active` | `src/modules/catalog/application/items/change-item-status.use-case.spec.ts` › `archives and restores back to inactive, never straight to active` |
| A plan without `catalog.items` | deactivation and archival `200`; reactivation and restoration `403 · platform.capability_not_entitled` | `test/e2e/catalog-items-access.e2e-spec.ts` › `refuses the owner of an organization whose plan lacks the catalog to create or edit with the entitlement code, but still lets it read, deactivate and archive`; `src/modules/catalog/application/items/change-item-status.use-case.spec.ts` › `lets a lapsed plan deactivate and archive but not reactivate or restore` |
| A member without `catalog.items.manage` | `403 · catalog.action_forbidden` on every transition, audited | `test/e2e/catalog-items-access.e2e-spec.ts` › `refuses a cashier, a waiter, a kitchen member and a manager of one business unit every item command with the plain denial, one denied record each and nothing written` |
| An item of another organization | `404 · catalog.not_found` on every transition | `test/e2e/catalog-items-isolation.e2e-spec.ts` › `answers an item of another organization exactly like an unknown id on the read, the edit and every transition, and changes nothing` |
| An archived item is absent from the point-of-sale reads (C17, C19) | not in the sellables read, not found by the search | `test/e2e/catalog-till-sellables.e2e-spec.ts` › `leaves out an inactive item, an archived item and an inactive variant, and keeps an item while one variant stays active` |
| An archived item is refused by admission (C20) | `CatalogNotSellableError` with `item_archived` for its line | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `refuses the whole admission, line by line, for an option of another item offer, an unavailable option, a count below the minimum or above the maximum, and an archived item` |

### C8 — Classify an item

`PUT /catalog/items/{itemId}/categories` with `categories: [{categoryId,
isPrimary}]` (at most 20, each once, at most one primary) and an optional
`If-Match`; gated like C4. Version row, then the item, then the requested
categories `FOR SHARE` in id order.

| Case | Expected answer | Proving test |
|---|---|---|
| Classify, switch the primary, empty the set; repeat the same set | `200` with the item and its tag, one `catalog.item.categories_set` record per real change, none for a repeat | `test/e2e/catalog-item-classification.e2e-spec.ts` › `classifies an item with one primary category, shows it in the item, its tag and the lists, replaces and empties the set, and records only real changes`; `src/modules/catalog/domain/items/item-classification.spec.ts` › `compares two classifications ignoring their order but not which link is primary` |
| Two primaries, an archived category, an unknown or foreign category, a repeated id, 21 links, a stale `If-Match`, an archived item | `422 · catalog.primary_category_conflict`, `422 · catalog.category_archived`, `404 · catalog.not_found`, `400`, `400`, `412 · foundation.precondition_failed`, `422 · catalog.item_archived`; nothing written | `test/e2e/catalog-item-classification.e2e-spec.ts` › `refuses two primaries, an archived category, an unknown or foreign category, a repeated id, more than twenty links, a stale If-Match and an archived item, writing nothing`; `src/modules/catalog/domain/items/item-classification.spec.ts` › `accepts no primary or one, never two` |
| An inactive category in the create and in the classification; a link kept after its category was archived; an archived category added | `201`, `200`, `200`; `422 · catalog.category_archived`, nothing written or recorded | `test/e2e/catalog-category-visibility.e2e-spec.ts` › `classifies an item into an inactive category in the create and in the classification, keeps a link to a category archived since, and refuses adding an archived one with catalog.category_archived, writing nothing`; `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `creates an item in an inactive category, which only hides it from navigation` |
| A link to an unknown or foreign category, or two primaries, reaching the repository | `404 · catalog.not_found` or `422 · catalog.primary_category_conflict`, never a constraint name | `src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts` › `refuses linking an unknown category, a category of another organization and two primaries with catalog failures that name no constraint` |
| 20 links, two primaries left to the domain; 21 links, a repeated id, a malformed id or link, an unknown field | accepted; `400` | `src/modules/catalog/presentation/dtos/items/classify-item-request.schema.spec.ts` › `accepts no link, and up to twenty distinct links, leaving the primary rule to the domain`; `src/modules/catalog/presentation/dtos/items/classify-item-request.schema.spec.ts` › `refuses %s` |
| A waiter; a lapsed plan | `403 · catalog.action_forbidden`; `403 · platform.capability_not_entitled` | `test/e2e/catalog-item-classification.e2e-spec.ts` › `refuses a waiter with the plain denial and an organization whose plan lost the catalog with the entitlement code` |

## Categories

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

`POST /catalog/categories`, `PATCH /catalog/categories/{categoryId}` (with
`parentCategoryId` it also moves the category: API v7 PR 3 replaced
`POST .../re-parenting`) and `POST /catalog/categories/{categoryId}/transitions`
with `{ transition }` (API v7 PR 3: it replaced the per-verb routes and added
`restore`); `catalog.items.manage` organization-wide; create, edit, move,
`reactivate` and `restore` gated by the plan, `deactivate` and `archive` never.

| Case | Expected answer | Proving test |
|---|---|---|
| Create a root and a child; a code taken in any case | `201` with the category and its depth, one `catalog.category.created` each; `409 · catalog.code_taken`, no constraint name | `test/e2e/catalog-categories.e2e-spec.ts` › `creates a root and a child category, active, answers them as the tree reads them, records each creation and refuses a taken code in any case` |
| The 500th category with two archived ones among them; the 501st; a reactivation at the limit | `201`; `422 · catalog.category_limit_reached`, nothing written or recorded, the tree still answered whole (`meta: {}`); `200`, a reactivation creates nothing | `test/e2e/catalog-limits.e2e-spec.ts` › `creates the 500th category, archived ones counted, refuses the 501st with nothing written and still reactivates one`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `refuse a category beyond the organization limit, counted under the version lock, before judging the parent or writing` |
| Edit code, name, description and order; the stored values again | `200`, one `catalog.category.updated` per real change, none for a repeat | `test/e2e/catalog-categories.e2e-spec.ts` › `edits the code, the name, the description and the order writing only real changes, and answers an identical edit without a record`; `src/modules/catalog/domain/categories/category-profile.spec.ts` › `lists only the fields that really change, with the order as text, and keeps the rest` |
| Move under another and back to the root; under itself or a descendant; a fourth level; an archived parent; an unknown or foreign parent | `200`; `422 · catalog.category_cycle`; `422 · catalog.category_depth_exceeded`; `422 · catalog.category_archived`; `404 · catalog.not_found`; nothing written | `test/e2e/catalog-categories.e2e-spec.ts` › `moves a category under another and back to the root, and refuses a cycle, a fourth level, an archived parent and an unknown or foreign parent, writing nothing`; `src/modules/catalog/domain/categories/category-hierarchy.spec.ts` › `judges $label` |
| Deactivate, reactivate, archive; repeats; reactivate or edit an archived category | `200`, one record per real change; `422 · catalog.transition_invalid`; `422 · catalog.category_archived` | `test/e2e/catalog-categories.e2e-spec.ts` › `deactivates, reactivates and archives a category, answers repeats without a record, refuses reactivating an archived one, and keeps it read-only` |
| A move to the root and a deactivation; reactivating an inactive root and a child of an active parent | each written and recorded once after the version row and the category | `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `move a category to the root and deactivate another, after locking the version and the category, writing and recording each once`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `reactivate an inactive root and an inactive category whose parent is active` |
| Restore an archived category under an archived parent, then the parent, then the category; a repeat; `reactivate` of an archived one; the former noun as a transition | `422 · catalog.category_archived`, nothing recorded; `200` with exactly `{ categoryId, status: inactive }` and the tag the read answers, one `catalog.category.restored` each; `200`, no record; `422 · catalog.transition_invalid` whose detail names `archived` and `restore`; `400` | `test/e2e/catalog-categories.e2e-spec.ts` › `restores an archived category as inactive under its parent once the parent is restored, answering lean with the tag the read answers, and teaches the transitions a status allows`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `restore an archived category to inactive under its live parent, judging the placement again, and record it`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `refuse to restore a category whose parent is archived, before writing anything`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `refuse a transition its status does not allow, naming the status and the transitions it allows` |
| Restore the third level of a chain under an archived parent, then under its restored parent; move the root of that chain, its archived third level included, under another root | `422 · catalog.category_archived`; `200`, `inactive` at depth 3; `422 · catalog.category_depth_exceeded`, because an archived category keeps counting toward the depth, so a restore can never land deeper than three levels; the depth refusal of a restore stays as a backstop | `test/e2e/catalog-categories.e2e-spec.ts` › `restores the third level of a chain only once its parent is live, at the same depth, and never lets an archived category end up deeper than three levels`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `refuse to restore a category that would nest deeper than three levels, before writing anything` |
| Rename and move in one `PATCH`; a move that closes a cycle sent with a rename; a move to the root | `200` with the new place and depth, one `catalog.category.updated` then one `catalog.category.moved`; `422 · catalog.category_cycle`, neither the name nor the place written; `200` | `test/e2e/catalog-categories.e2e-spec.ts` › `renames and moves a category in one PATCH, records the edit and the move, and writes neither when the move is refused`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `rename and move a category in one locked transaction, judging the placement before any write and recording each fact once`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `refuse an edit that moves a category under its own subcategory or an unknown parent before writing anything` |
| A `PATCH` holding only `parentCategoryId`, by a member without the permission; a `parentCategoryId` that is not a UUID; the status | the refusal is audited as `catalog.category.moved`; `400`; `400` | `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `admit an edit that only moves the category under the move action`; `src/modules/catalog/presentation/dtos/categories/category-request.schemas.spec.ts` › `refuses %j` |
| A branch that loops back on itself or runs thousands of levels down | `422 · catalog.category_depth_exceeded`, no stack overflow | `src/modules/catalog/domain/categories/category-hierarchy.spec.ts` › `refuses as too deep, without overflowing the stack, a branch that loops back on itself or runs thousands of levels down` |
| Create, edit, move and reactivate under an inactive parent; archive a category under it | `201` and `200`, each answer carrying `isEffectivelyActive` and `blockedByAncestorId` = that parent | `test/e2e/catalog-category-visibility.e2e-spec.ts` › `creates, edits, moves and reactivates categories under an inactive parent, each answer naming the parent as the blocking ancestor`; `src/modules/catalog/domain/categories/category-hierarchy.spec.ts` › `judges $label` |
| Create a child under an archived parent or move a category under it; reactivate its inactive child | `422 · catalog.category_archived`, nothing written; `200`, the child active but hidden by the archived parent, whose status is never judged | `test/e2e/catalog-category-visibility.e2e-spec.ts` › `refuses creating a child under an archived parent and moving a category under it with catalog.category_archived, writing nothing, but reactivates its inactive child, which the archived parent keeps hidden`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `reactivate a category under an inactive parent without judging its placement, and answer it hidden by that parent` |
| Deactivate, reactivate and archive the categories of an item and their ancestors | the item and its variants keep status `active`; the item list still filters by the category | `test/e2e/catalog-category-visibility.e2e-spec.ts` › `never changes the status of an item or of its variants when its categories or their ancestors are deactivated, reactivated or archived, and the item list still filters by the category` |
| Unknown or forged fields, a NUL byte, a blank or long name, a bad code, a negative order, an empty edit | `400 · foundation.validation_failed`, nothing written | `test/e2e/catalog-categories.e2e-spec.ts` › `refuses unknown or forged fields, a NUL byte, a blank or long name, a bad code, a negative order and an empty edit with 400 and writes nothing` |
| A cashier, a waiter, a manager of one unit | `403 · catalog.action_forbidden`, one denied record each, nothing written | `test/e2e/catalog-categories-access.e2e-spec.ts` › `refuses a cashier, a waiter and a manager of one business unit every category command with the plain denial, one denied record each and nothing written` |
| A lapsed plan | create, edit, move, `reactivate` and `restore` `403 · platform.capability_not_entitled`, the archived category left archived; `deactivate` and `archive` `200` | `test/e2e/catalog-categories-access.e2e-spec.ts` › `refuses an organization whose plan lost the catalog to create, edit, move, reactivate or restore a category with the entitlement code, but lets it deactivate and archive` |
| A category of another organization | `404 · catalog.not_found` on every command, identical to an unknown id | `test/e2e/catalog-categories-access.e2e-spec.ts` › `answers a category of another organization exactly like an unknown id on every command and changes nothing` |
| No or a malformed `Idempotency-Key` on the create | `400 · foundation.validation_failed`, nothing written | `test/e2e/catalog-idempotent-creates.e2e-spec.ts` › `requires a UUID Idempotency-Key on every catalog create and writes nothing without one` |
| The same key and request again, also after the category was renamed | `201`, `Idempotent-Replayed: true`, the category as it is now with its current ETag, nothing written or recorded | `test/e2e/catalog-idempotent-creates.e2e-spec.ts` › `replays the same key and request on every catalog create with 201, Idempotent-Replayed: true and the resource as it is now, even after a rename, writing and recording nothing`; `src/modules/catalog/application/categories/create-category.use-case.spec.ts` › `replays the same key and request with the category as it is now, also after a rename, writing and recording nothing`; `src/modules/catalog/application/categories/create-category.use-case.spec.ts` › `looks the key up under the version lock and creates the category with the key and the hash of the request` |
| The same key at the 500-category limit | `201` replayed: the key is looked up right after the version row, before the limit, the parent and the code | `src/modules/catalog/application/categories/create-category.use-case.spec.ts` › `replays a category created with the key even when the organization already holds as many categories as allowed` |
| The same key with another request; a taken code under another key | `409 · idempotency.key_reused`, whatever the category looks like now; `409 · catalog.code_taken`; nothing written | `test/e2e/catalog-idempotent-creates.e2e-spec.ts` › `refuses a reused key with another request with idempotency.key_reused and a taken code under a new key with catalog.code_taken on every catalog create`; `src/modules/catalog/application/categories/create-category.use-case.spec.ts` › `refuses the same key with another request, whatever the category looks like now, and writes nothing` |
| The same key in another organization; the key in the answer or the audit trail | an independent `201` there; never answered, never recorded | `test/e2e/catalog-idempotent-creates.e2e-spec.ts` › `keeps the creation keys of each organization apart and never answers or records a key on every catalog create` |
| Parallel retries of one key; parallel creates that share a key with different requests | one category, one record, every other answer `201` replayed; one `201`, the others `409 · idempotency.key_reused`; no `500` | `test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts` › `creates one resource for parallel retries of one key on every catalog create and replays it to every other caller, with no 500 and one record`; `test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts` › `lets one of parallel creates that share a key with different requests win on every catalog create and refuses the others with idempotency.key_reused` |
| `GET /catalog/categories/{categoryId}`; `If-None-Match` weak or strong; the same tag after a rename | `200`, the category with its depth and effective visibility, a strong ETag and `Cache-Control: private, no-cache`; `304` with no body; `200` with a new tag (D24) | `test/e2e/catalog-category-preconditions.e2e-spec.ts` › `reads one category with its strong ETag and private, no-cache, answers 304 to a matching If-None-Match, weak or strong, and a new tag once the category was renamed`; `src/modules/catalog/application/categories/category-entity-tag.spec.ts` › `changes when any field of the category itself changes` |
| Every category write: create, edit, move, deactivate, reactivate, archive | the category (a transition answers `{ categoryId, status }`) with the strong ETag the read answers and `Cache-Control: private, no-store`; no `Idempotent-Replayed` header outside a replay | `test/e2e/catalog-category-preconditions.e2e-spec.ts` › `answers every category write with the ETag of the category it returns, the tag the read answers, and private, no-store` |
| A stale or weak `If-Match` on the edit, the move and every lifecycle route; the current tag | `412 · foundation.precondition_failed`, checked right after the version row and the category, nothing written; `200` with a new tag; without the header the write is not conditional | `test/e2e/catalog-category-preconditions.e2e-spec.ts` › `refuses a stale If-Match on the edit, the move and every lifecycle route with 412 foundation.precondition_failed, writing nothing, and accepts the current tag`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `refuse a stale If-Match right after locking the version and the category, before any write or record, and accept the current tag`; `src/modules/catalog/application/categories/category-entity-tag.spec.ts` › `matches its own strong tag in If-Match, never a weak one or another category` |
| The parent of a category is deactivated | the category's tag changes, because `isEffectivelyActive`, `blockedByAncestorId` and `depth` are part of what it answers; its old tag answers `412` | `test/e2e/catalog-category-preconditions.e2e-spec.ts` › `changes the tag of a subcategory when its parent is deactivated, because its effective visibility is part of what it answers, and refuses the old tag with 412`; `src/modules/catalog/application/categories/category-entity-tag.spec.ts` › `changes when the visibility or the depth derived from the ancestors changes, because they are part of what a reader caches` |
| A move of the tree committed between the statement that reads a category and the one that reads its hierarchy | `GET /catalog/categories/{categoryId}` answers its depth, `isEffectivelyActive`, `blockedByAncestorId` and ETag from one read-only snapshot: the state before the move or the state after it, never the depth of one with the blocking ancestor of the other | `test/e2e/catalog-category-read-race.e2e-spec.ts` › `answers the depth and the blocking ancestor of one version of the tree when the move commits between the category and its hierarchy` |
| The read by a manager at its unit; by a cashier, with a known or unknown id, or by the manager without its unit; an unknown or foreign category; a malformed id | `200`; the same plain `403 · catalog.action_forbidden` before any lookup; the same `404 · catalog.not_found`; `400` | `test/e2e/catalog-category-preconditions.e2e-spec.ts` › `lets a manager of one unit read a category at that unit, answers a member without catalog.items.view the same plain 403 for a known or unknown id, and an unknown or foreign category the same 404` |
| One key used for a category and for a modifier group of the same organization | both `201`, independent; each replay answers its own resource | `test/e2e/catalog-idempotent-creates-access.e2e-spec.ts` › `keeps one key used for a category and for a modifier group of the same organization apart, and replays each its own resource` |
| The current tag sent weak in `If-Match` | `412 · foundation.precondition_failed`, nothing written, the tag unchanged | `test/e2e/catalog-category-preconditions.e2e-spec.ts` › `refuses the current tag sent weak in If-Match with 412 foundation.precondition_failed and writes nothing, because a write compares strongly` |
| A new field in the category answer | the tag hashes exactly the answered fields in their order, so it must change with it | `src/modules/catalog/presentation/dtos/categories/category-response.spec.ts` › `tags exactly the fields the category answer carries, in the order it answers them, so a new answer field forces a new tag` |

## Modifiers

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

`POST /catalog/modifier-groups`, `GET /catalog/modifier-groups`,
`GET /catalog/modifier-groups/{modifierGroupId}`,
`GET /catalog/modifier-groups/{modifierGroupId}/offers` (which items offer
the group), `PUT` of the group with its whole option set (API v7 PR 5;
it replaced the `PATCH` of the group and the `POST` and `PATCH` of an option), and the lifecycle pairs
of the group and of an option. Writes need `catalog.items.manage`
organization-wide (create, a set that does more than withdraw options, and reactivation gated by
`catalog.items`; deactivation and archival never); reads need
`catalog.items.view` as the other catalog reads. The group read and every
group write carry a strong ETag that is a hash of the group and its options; the `PUT` requires it in `If-Match`.

| Case | Expected answer | Proving test |
|---|---|---|
| Create a group with instruction and variant options; read it | `201`, `Cache-Control: private, no-store`, options in the order sent with derived codes, one `catalog.modifier_group.created`; the read answers the same body and tag, `304` on `If-None-Match` | `test/e2e/catalog-modifier-groups.e2e-spec.ts` › `creates a group with instruction and variant options in order, records one creation, and answers the same body and strong ETag the read answers`; `src/modules/catalog/domain/modifiers/modifier-option.spec.ts` › `derives the missing codes from the names, never repeating a code sent or already taken, ignoring case` |
| `isDefault` on an option of the create; the group answer | `400 · foundation.validation_failed`; no option carries a default, which belongs to each item offer (C13) | `test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts` › `keeps no default on the reusable option: the field is refused on the group and absent from its answer` |
| The 500th group with an archived one among them; the 501st; a reactivation at the limit | `201`; `422 · catalog.modifier_group_limit_reached`, nothing written, the list still answered whole (`meta: {}`); `200` | `test/e2e/catalog-limits.e2e-spec.ts` › `refuses a modifier group once the organization holds 500 of any status, and still reactivates one`; `src/modules/catalog/application/modifiers/create-modifier-group.use-case.spec.ts` › `refuses a group at the limit before any variant lock or write, recording nothing`; `src/modules/catalog/application/modifiers/create-modifier-group.use-case.spec.ts` › `counts the groups of the organization under the version lock and creates one below the limit` |
| A group code taken in any case; an option on a variant of another organization, an unknown one, an inactive or an archived one | `409 · catalog.code_taken` with no constraint name; `404 · catalog.not_found`; `422 · catalog.variant_not_active`; nothing written | `test/e2e/catalog-modifier-groups.e2e-spec.ts` › `refuses a taken group code in any case with catalog.code_taken, and an unknown, foreign, inactive or archived variant, writing nothing` |
| Unknown or forged fields (`organizationId`, `status`, a price), a NUL byte, a blank or long name, a bad or missing group code, a kind without its target, a quantity out of 1 to 999, repeated option codes, 51 options | `400 · foundation.validation_failed`, nothing written | `test/e2e/catalog-modifier-groups.e2e-spec.ts` › `refuses unknown or forged fields, a NUL byte, a blank or long name, a bad code, a kind without its target, a quantity out of range, repeated option codes and more than 50 options with 400` |
| Set the code, the name, the description and the order of the options with `If-Match`; no `If-Match`; a stale tag; the same set again; description absent; a taken group code; an empty body, a forged field, the old `optionOrder`, no code, or an option repeated | `200` with a new tag equal to the read's; `428 · precondition.required`; `412 · foundation.precondition_failed`; `200` without a record; `200` with no description; `409 · catalog.code_taken`; `400` | `test/e2e/catalog-modifier-groups.e2e-spec.ts` › `sets the code, the name, the description and the order of the options in one PUT, requiring If-Match, writing only real changes and answering an identical set without a record`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `keeps every id sent back, numbers the order sent and archives an option left out after the sent ones`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `summarizes the live options in their order with every compared field and ignores archived ones and their position` |
| Deactivate, reactivate, archive and restore a group; repeats; reactivate or deactivate an archived group; edit it | `200` with `{ modifierGroupId, status }` and the group tag, one record per real change, the options keep their status; `422 · catalog.transition_invalid`; `422 · catalog.modifier_group_archived`; still readable | `test/e2e/catalog-modifier-groups.e2e-spec.ts` › `deactivates, reactivates, archives and restores a group, answers lean with the tag the read answers and repeats without a record, and keeps an archived group read-only but readable until it is restored` |
| Restore an archived group; restore an archived option, of a live group or of an archived one; a transition the status does not allow (API v7 PR 3) | `inactive`, one `catalog.modifier_group.restored`, no offer default touched; `inactive`, one `catalog.modifier_group.option_restored`; `422 · catalog.modifier_group_archived`, nothing written; `422 · catalog.transition_invalid` naming the group or option, its status and the transitions it allows | `src/modules/catalog/application/modifiers/modifier-transitions.use-case.spec.ts` › `restore an archived group to inactive after the version and the group locks, without touching any offer default, and record it`; `src/modules/catalog/application/modifiers/modifier-transitions.use-case.spec.ts` › `restore an archived option of a live group to inactive without touching any offer default, and record it on the group`; `src/modules/catalog/application/modifiers/modifier-transitions.use-case.spec.ts` › `refuse to restore an option of an archived group, writing nothing`; `src/modules/catalog/application/modifiers/modifier-transitions.use-case.spec.ts` › `refuse a transition the status does not allow, naming the group or the option, its status and the transitions it allows` |
| List the groups; filter by status; an unknown status | `200` ordered by name with `optionCount` and `activeOptionCount`, `meta: {}`; `400` | `test/e2e/catalog-modifier-groups.e2e-spec.ts` › `lists the groups by name with their option counts, filters by status and refuses an unknown status` |
| Add an instruction and a variant option to the set; a code repeated in the set; a code held by an archived option of the group; a stale `If-Match` | `200` in the order sent, existing ids kept, one `catalog.modifier_group.updated` with `catalog.modifier_group.option_set` before and after; `400` pointing at `options[3].code`; `409 · catalog.code_taken` pointing at the option; `412` | `test/e2e/catalog-modifier-options.e2e-spec.ts` › `adds an instruction and a variant option to the set in the order sent, refuses a code repeated in the set or held by another option in any case, and honours If-Match`; `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `locks the version then the group, asks about the plan, share-locks the new variants, then writes the profile, the changed and the new options and one record`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `points at the id of the group never had, at a code another option holds and at a target an option never changes`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `refuses a code without an id whose option another entry keeps by its id`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `keeps the stored code of an option sent back without one and derives a free code for a new one, never one taken by an archived option` |
| A new option on a variant of another organization, an unknown one or an inactive one | `404 · catalog.not_found`, `404`, `422 · catalog.variant_not_active`, each pointing at `options[i].variantId`; nothing written | `test/e2e/catalog-modifier-options.e2e-spec.ts` › `refuses a new option pointing to a variant of another organization or an unknown one with 404, and to an inactive variant with 422, at the option, writing nothing`; `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `points at a new option whose variant is not active after share-locking it, writing nothing`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `judges the variant of new options only: unknown is not found, anything but active is not active` |
| Edit name and quantity in the set; the same values again; another kind or variant for a kept option; an option id of another group; a status, an `isDefault`, a zero quantity or a kind without its target | `200`, one `catalog.modifier_group.updated`; `200` without a record; `422 · catalog.modifier_option_target_fixed` pointing at `options[i].kind` or `.variantId`; `404 · catalog.not_found` pointing at `options[i].optionId`; `400` | `test/e2e/catalog-modifier-options.e2e-spec.ts` › `edits the name and the quantity of an option writing only real changes, and refuses a target change, an option of another group and malformed entries`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `points at the variant of an option that keeps its kind but names another variant`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `writes only the rows whose stored values differ, new ones included` |
| Deactivate, reactivate, archive and restore an option; repeats; reactivate an archived option; an option left out of the set, then its code sent again; a suspended option | `200` with `{ optionId, status }` and the group tag, the group and the other options keep their status; `422 · catalog.transition_invalid`; left out it is `archived` with its id, sent again it returns `active` with the same id; `suspended: true` makes it `inactive` | `test/e2e/catalog-modifier-options.e2e-spec.ts` › `deactivates, reactivates, archives and restores an option without touching its group or the other options, answers lean with the tag of the group and repeats without a record, and lets the set edit an option the transition restored`; `test/e2e/catalog-modifier-options.e2e-spec.ts` › `archives an option left out of the set with its id, brings it back with the same id when its code is sent again, and suspends one without the plan`; `test/e2e/catalog-modifier-options.e2e-spec.ts` › `brings an archived option back by its id with the same id and the status sent, and asks a lapsed plan for it because it is more than a withdrawal`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `takes back an option by its code ignoring case when no id is sent, archived ones included, and makes a suspended one inactive`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `names the options that stop being sellable, never one that was archived or stays active`; `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `lets a lapsed plan suspend and archive options, removing the defaults of the options leaving sale, never asking about the plan` |
| Deactivate or archive an option two items preselect; deactivate a group, or the variant a `catalog_variant` option sells; reactivate the option | the option leaves `defaultOptionIds` of every offer in the same transaction, both item tags change, the record names how many offers lost it (`catalog.modifier_group.offer_defaults_removed`, `catalog.item.variant.offer_defaults_removed`); reactivation never adds it back | `test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts` › `removes a deactivated option from the defaults of every offer, changes their tags, records how many offers lost it and never adds it back on reactivation`; `test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts` › `removes the defaults of a deactivated group and of an option whose target variant is deactivated`; `src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts` › `an option %s removes it from every offer default right after its status and records how many offers lost it`; `src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts` › `reactivating an option never touches the defaults, and an option no offer preselected records no removal`; `src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts` › `a group deactivation removes the defaults of its options and records it`; `src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts` › `a variant deactivation removes the defaults of the options that sell it and records it on the item` |
| A 51st option, archived options counted | `422 · catalog.modifier_group_full`, no row, no record, the group tag unchanged | `test/e2e/catalog-modifier-options.e2e-spec.ts` › `refuses a 51st option, archived options included, with catalog.modifier_group_full and writes nothing, not even a change record`; `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `refuses a 51st option, archived ones counted, with catalog.modifier_group_full before any variant lock or write` |
| A set that adds or edits an option, or a transition of an option, of an archived group | `422 · catalog.modifier_group_archived`, the group tag unchanged | `test/e2e/catalog-modifier-options.e2e-spec.ts` › `refuses every option command on an archived group with catalog.modifier_group_archived and writes nothing`; `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `refuses an archived group whatever the set, writing nothing` |
| Cashier, waiter, kitchen, bar, a member without a role and a manager of one unit, with existing and unknown ids, on every command | the same `403 · catalog.action_forbidden`, one denied record each, nothing written | `test/e2e/catalog-modifiers-access.e2e-spec.ts` › `refuses a cashier, a waiter, a kitchen and a bar member, a member without a role and a manager of one business unit every modifier command with the plain denial, with existing and unknown ids, one denied record each and nothing written`; `src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts` › `refuse a member without the manage permission with one denied record and no lock, on a group and on an option` |
| The same members on the two reads, with an existing, unknown or no group and any unit; a manager of one unit with and without its unit | `403 · catalog.action_forbidden`, nothing recorded; `200` at its unit, `403` without it | `test/e2e/catalog-modifiers-access.e2e-spec.ts` › `answers the members without catalog.items.view the same plain denial on every modifier read, with an existing, an unknown or no group and any unit, records nothing, and lets a manager of one unit read at that unit only` |
| A lapsed plan | create, a set that renames, adds or edits an option, reactivations and an offer that adds a group `403 · platform.capability_not_entitled`; reads, a set that only suspends options, deactivations and archivals `200` | `test/e2e/catalog-modifiers-access.e2e-spec.ts` › `refuses an organization whose plan lost the catalog to create, edit, add, reactivate and offer with the entitlement code, but lets it read, suspend options in the set, and deactivate and archive groups and options`; `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `asks about the plan when the set reorders or adds, and records the entitlement refusal without writing`; `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `asks about the plan when only the name changes, even while options are only withdrawn`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `calls a set that only suspends or archives options, keeping the order of the rest, a withdrawal` |
| A group, an option or an item of another organization, alone or mixed with the caller's own ids | `404 · catalog.not_found`, identical to an unknown id; the foreign group unchanged | `test/e2e/catalog-modifiers-access.e2e-spec.ts` › `answers a group, an option and an item of another organization exactly like unknown ids on every command and read, and changes nothing there` |
| `X-Organization-Id` of an organization the caller does not belong to | `403 · tenant.context_rejected` before any catalog lookup | `test/e2e/catalog-modifiers-access.e2e-spec.ts` › `refuses a caller naming an organization it does not belong to before any catalog lookup` |
| No or a malformed `Idempotency-Key` on the group create (the option add is gone since API v7 PR 5); the same key and request again, also after a rename; another request; a taken code under another key; another organization | `400`; `201`, `Idempotent-Replayed: true` and the group as it is now, nothing written or recorded; `409 · idempotency.key_reused`; `409 · catalog.code_taken`; an independent `201`, the key never answered or recorded | `test/e2e/catalog-idempotent-creates.e2e-spec.ts` › `requires a UUID Idempotency-Key on every catalog create and writes nothing without one`; `test/e2e/catalog-idempotent-creates.e2e-spec.ts` › `replays the same key and request on every catalog create with 201, Idempotent-Replayed: true and the resource as it is now, even after a rename, writing and recording nothing`; `test/e2e/catalog-idempotent-creates.e2e-spec.ts` › `refuses a reused key with another request with idempotency.key_reused and a taken code under a new key with catalog.code_taken on every catalog create`; `test/e2e/catalog-idempotent-creates.e2e-spec.ts` › `keeps the creation keys of each organization apart and never answers or records a key on every catalog create`; `src/modules/catalog/application/modifiers/modifier-creations.use-case.spec.ts` › `replays a group created with the same key and request before counting the groups or writing, and records nothing`; `src/modules/catalog/application/modifiers/modifier-creations.use-case.spec.ts` › `refuses a group create that reuses a key with another request` |
| Parallel retries of one key on the group create and on the option add; parallel requests that share a key | one group or one option, one record, the others `201` replayed; one `201`, the others `409 · idempotency.key_reused`; no `500` | `test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts` › `creates one resource for parallel retries of one key on every catalog create and replays it to every other caller, with no 500 and one record`; `test/e2e/catalog-idempotent-creates-concurrency.e2e-spec.ts` › `lets one of parallel creates that share a key with different requests win on every catalog create and refuses the others with idempotency.key_reused` |
| A retry of the group create or the option add after the member lost the permission or the plan lost the catalog | the plain denial or the entitlement code, never the replay | `test/e2e/catalog-idempotent-creates-access.e2e-spec.ts` › `answers a retry of every catalog create by a member who lost the permission with the plain denial, never the replay`; `test/e2e/catalog-idempotent-creates-access.e2e-spec.ts` › `answers a retry of every catalog create of an organization whose plan lost the catalog with the entitlement code, never the replay` |
| Which items offer a group: two items with their rules, position and defaults, one inactive; an item offering only another group; `status`; `businessUnitId` | `200`, the items by name with `itemId`, `code`, `name`, `status`, `minSelections`, `maxSelections`, `sortOrder` and `defaultOptionIds` in the order of the group, `Cache-Control: private, no-cache`, no `ETag`, `meta.pageInfo`; the other item absent; only the items in that status; the same answer at the unit | `test/e2e/catalog-modifier-group-offers.e2e-spec.ts` › `answers every item that offers the group by name with its code, status, rules, position and preselected options, private, never tagged, and nothing of the items that do not offer it`; `src/modules/catalog/infrastructure/modifiers/modifier-group-offers.queries.spec.ts` › `reads the first page of every live offer of the group, item-level or variant override but never an opt-out, by item name key and offer id, one row beyond the limit` |
| A group no item offers; a deactivated, then archived group that items offer | `200` with an empty page and null cursors; `200` with its items, history kept | `test/e2e/catalog-modifier-group-offers.e2e-spec.ts` › `answers an empty page for a group no item offers, and still answers the offers of a deactivated or archived group` |
| Pages of three while other items start offering the group between the pages; a page back | no gap and no duplicate among the items that offered it first, in name order; the page before the second equals the first | `test/e2e/catalog-modifier-group-offers.e2e-spec.ts` › `pages forward and back by name without a gap or a duplicate while other items start offering the group between the pages`; `src/modules/catalog/infrastructure/modifiers/modifier-group-offers.queries.spec.ts` › `continues after a position and walks back before one with the inverse order, keeping the status filter` |
| A malformed limit, status, unit or group id, an unknown parameter, both cursors; a tampered cursor; a cursor of another group, status, unit or organization | `400 · foundation.validation_failed`; `400 · collection.cursor_tampered`; `400 · collection.cursor_incompatible` | `test/e2e/catalog-modifier-group-offers.e2e-spec.ts` › `refuses a malformed query, and a tampered cursor or one issued for another group, filter, unit or organization` |
| Cashier and waiter (sellables only), kitchen, bar and a member without a role on the offers of an existing, an unknown and another organization's group, with any unit and any cursor | the same `403 · catalog.action_forbidden`, decided before the cursor or any lookup, nothing recorded | `test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts` › `answers a cashier, a waiter (who only see sellables), a kitchen and a bar member and a member without a role the same plain denial with an existing, an unknown and another organization group, any unit and any cursor, and records nothing`; `src/modules/catalog/application/modifiers/read-modifier-groups.use-case.spec.ts` › `refuses a member without the view permission plainly, for any group and unit, before decoding the cursor or looking anything up` |
| A manager of one unit on the offers: at its unit; without a unit, at another unit of the organization, at an unknown unit or a foreign unit | `200`, and `404 · catalog.not_found` for an unknown group; the same plain `403` for every other unit, existing group or not | `test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts` › `lets a manager of one business unit read at that unit only, never without it, at another unit of the organization, at an unknown unit or at a unit of another organization` |
| The offers of an unknown group and of another organization's group, for an organization-wide reader; an unknown unit | the identical `404 · catalog.not_found`; `404 · organization.structure_not_found` | `test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts` › `answers the organization-wide reader an unknown group and another organization group with the identical not found, and an unknown unit with the structure code`; `src/modules/catalog/application/modifiers/read-modifier-groups.use-case.spec.ts` › `answers not found for an unknown group without listing anything` |
| The offers of a group on a lapsed plan | `200`: reads never ask about the plan | `test/e2e/catalog-modifier-group-offers-access.e2e-spec.ts` › `keeps the read open to the owner of an organization whose plan lost the catalog` |
| Where-used of a group that an item offers, one variant overrides and another variant opts out of (K9g) | the item offer with `variantId: null` and the override with its `variantId`, never the opt-out; a group only opted out of answers an empty page | `test/e2e/catalog-variant-offer-route.e2e-spec.ts` › `lists the override of a group with its variant in the where-used read, and never an opt-out` |
| A text option with a length, a prefix and a print hint switched off; the same set sent back (K14c) | `200`; the option answers `kind: "text"`, `textMaxLength`, `prefix` and `printsOnReceipt: false`, an option with defaults carries none of those keys, and the set sent back is already in effect (same body, same tag) | `test/e2e/catalog-option-presentation.e2e-spec.ts` › `stores a text option with its length, prefix and print hints, answers them only when they differ from the default and keeps them when the set is sent back`; `src/modules/catalog/presentation/dtos/items/item-response.spec.ts` › `answers the kind, text length, prefix and print hints of an option only when they differ from the default` |
| The prefix and the print hints of an instruction option, set and then cleared | `200` each time; cleared, the option answers no presentation key again and the tag moves with every real change | `test/e2e/catalog-option-presentation.e2e-spec.ts` › `writes the prefix and the print hints of an instruction option and changes them later, one write moving the tag`; `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `writes an option whose prefix or print hints changed and counts that as more than a withdrawal` |
| A group created with a text option and an extra-sauce option | `201` with `kind: "text"` and `maxQuantity` 1, and the instruction with `prefix: "extra"` and `maxQuantity` 3 | `test/e2e/catalog-option-presentation.e2e-spec.ts` › `creates a group whose options already carry a kind, a prefix and a text length`; `src/modules/catalog/application/modifiers/create-modifier-group.use-case.spec.ts` › `refuses an option whose kind, quantity, prefix and text length disagree before any write, naming the option and the rule` |
| A text option without a length or with two units or a prefix other than `add`; a length on an instruction; `light` with two units; `no` on a variant option | `422 · catalog.modifier_option_presentation_invalid` listing each option by name and the rule at `options[n].textMaxLength`, `.maxQuantity` or `.prefix`, nothing written | `test/e2e/catalog-option-presentation.e2e-spec.ts` › `refuses options whose rules disagree with 422, naming each option and the rule, and writes nothing`; `src/modules/catalog/domain/modifiers/option-presentation.spec.ts` › `asks light, no, on_the_side and substitute for a maximum quantity of one`; `src/modules/catalog/domain/modifiers/option-presentation.spec.ts` › `holds a text option to exactly one unit and to no prefix or the add prefix`; `src/modules/catalog/presentation/failures/option-set-refusal-failure.spec.ts` › `answers 422 for the presentation rule %s naming the option and the rule it breaks` |
| A text length of 0, 501 or 1.5, a prefix outside the six, a print hint that is not a boolean, a text option that names a variant | `400 · foundation.validation_failed` at `options[n].textMaxLength`, `.prefix`, `.printsOnReceipt` or the option | `test/e2e/catalog-option-presentation.e2e-spec.ts` › `refuses a malformed presentation at the field with 400`; `src/modules/catalog/presentation/dtos/modifiers/modifier-request.schemas.spec.ts` › `refuses a text length outside 1 to 500, a prefix outside the six and print hints that are not booleans` |
| Two writers of a text option sent at the same version | one `200` and one `412`, the group holds one text option | `test/e2e/catalog-option-presentation.e2e-spec.ts` › `lets one of two writers of a text option at the same version win and answers the other 412` |
| The database refuses a text option without a length, a prefix outside the six, a prefix that needs one unit with more, and a prefix that does not fit the kind | `modifier_options_text_max_length_chk`, `modifier_options_prefix_chk`, `modifier_options_prefix_quantity_chk`, `modifier_options_prefix_kind_chk` and `modifier_options_text_shape_chk`; an option written as before is an instruction with both hints on | `database/tests/contracts/catalog-option-kind-contracts.ts` › `a text option that names a variant is refused`; `database/tests/contracts/catalog-option-kind-contracts.ts` › `an option written without the new columns is an instruction with no length, no prefix and both hints on` |

### C13 — Offer modifier groups on an item

`PUT /catalog/items/{itemId}/modifier-groups` with the ordered list of
groups and their selection rule on this item; needs `catalog.items.manage`
organization-wide, and the `catalog.items` capability unless the list only
removes groups (owner decision: a lapsed plan can always detach); accepts
the item's `If-Match`.

| Case | Expected answer | Proving test |
|---|---|---|
| A required single choice and an optional multiple choice, in order; the same list again; a stale tag; an empty list | `200` with the item and its `modifierGroups[]` (groups, rules and options), its new tag equal to the read's, one `catalog.item.modifier_groups_set`; `200` without a record; `412`; `200` with no group | `test/e2e/catalog-modifier-offers.e2e-spec.ts` › `offers a required single choice and an optional multiple choice in order, answers the item with its groups and options, records once and replays an identical list without a record`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `compares two offers in order, rules included`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `summarises an offer as its ordered offer ids with their groups, rules and statuses, or null when empty` |
| A minimum above the maximum or above the units the available options of the group can reach (the sum of their maximum quantities, F1); a new inactive or archived group; a negative minimum, a zero maximum, a repeated group, a variant scope, 21 groups | `422 · catalog.selection_rule_invalid`; `422 · catalog.modifier_group_not_active`; `400`; nothing written | `test/e2e/catalog-modifier-offers.e2e-spec.ts` › `refuses a minimum above the maximum or above the active options, and a new inactive or archived group, writing nothing`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `refuses %s`; `src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts` › `refuse a required group whose available options cannot reach its minimum in units, replacing nothing` |
| Two defaults on a burger whose maximum is one (the reproduced case); one default | `422 · catalog.default_options_invalid`, the item tag unchanged; `200` with `defaultOptionIds` | `test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts` › `refuses two defaults on a burger whose maximum is one, writing nothing, and accepts one`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `refuses %s`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `accepts no default, or defaults that are available options of the group up to the maximum`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `judges the selection rule before the defaults`; `src/modules/catalog/application/modifiers/modifier-defaults.use-case.spec.ts` › `offers the defaults sent and refuses defaults beyond the rule before replacing anything` |
| The same group with different defaults on two items; the same defaults again in another order | each item answers its own `defaultOptionIds` in the order of the group, recorded as `offerId:groupId:min:max:status:sorted defaults`; `200` without a write or a record, the same tag | `test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts` › `gives the same group different defaults on two items, answers them in option order, replays an identical list without a write and keeps them off the group`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `compares the defaults of two offers as a set and keeps an identical kept offer unjudged`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `summarises the defaults of a group after its status, sorted, only when it has some` |
| A default that is unknown, of another group, of another organization or inactive; a repeated default; 51 defaults | the same `422 · catalog.default_options_invalid` for all four; `400 · foundation.validation_failed`; `400`, since a group holds at most 50 options | `test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts` › `refuses as one identical 422 a default that is unknown, of another group, of another organization or inactive`; `src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts` › `accepts as many defaults as a group can hold options, and refuses one more` |
| An extra whose target variant is active but whose item is archived or inactive, as a default | the same `422 · catalog.default_options_invalid`: the command judges defaults with the structural `activeOption` of the till | `test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts` › `refuses to preselect an extra whose target variant is active but whose item is archived or inactive, as the till would not offer it`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `needs an active option whose target variant, when it has one, is active and belongs to an active item, the structural rule of the writes, which have no unit to judge listing for` |
| One option chosen up to twice offered with a minimum and a maximum of two (F1); an extra worth two units whose target item is paused, not counted | `200`; with the target paused, a changed rule whose minimum the options left cannot reach answers `422 · catalog.selection_rule_invalid` and a reachable one `200` | `test/e2e/catalog-unit-capacity.e2e-spec.ts` › `offers one option chosen up to twice with a minimum of two, sells the item everywhere and admits exactly two units`; `test/e2e/catalog-unit-capacity.e2e-spec.ts` › `counts no unit of an extra whose target item is paused, in the offer command as in every reader`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `accepts a minimum that the available options can reach through their quantities`; `src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts` › `hands the offer command every available option of a group with its maximum quantity, judged by the structural rule of the writes, which has no unit to judge listing for, so it can count units` |
| A lapsed plan detaches a group while the kept group keeps its defaults; it only edits defaults | `200`, the kept defaults unchanged; `403 · platform.capability_not_entitled` (a change of defaults is a rule change) | `test/e2e/catalog-modifier-offer-defaults.e2e-spec.ts` › `lets a lapsed plan detach a group while the kept defaults stay, but gates a change that only edits defaults`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `never calls an offer shrinking when a kept group changes its defaults` |
| A default that starts above one unit on a multiple choice (`defaultQuantities` by option id); the same list again; the quantity lowered to one; a body without `defaultQuantities` (K14a) | `200` with `defaultQuantities` on the item and on the offers of the group, only while a default is above one unit; the identical list writes nothing and keeps the tag; a changed quantity moves the tag; the record shows `o1*3` after the option id | `test/e2e/catalog-offer-default-quantities.e2e-spec.ts` › `stores and answers the quantity on the item and on the group offers, replays it without a write and moves the item tag when it changes`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `counts a quantity change of a default as a change of the offer, so the item tag moves, and never as a shrinking one`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `writes the quantity of a default after its id, only when it is above one` |
| A quantity above the maximum of its option; quantities that add up to more than `maxSelections` (K14a) | `422 · catalog.default_quantities_invalid` pointing at `offers[i].defaultQuantities` with a message that names the rules; the item tag unchanged; the defaults are judged first, so an unknown default is still `catalog.default_options_invalid` | `test/e2e/catalog-offer-default-quantities.e2e-spec.ts` › `refuses %s with a 422 that points at defaultQuantities and writes nothing`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `refuses %s`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `judges the defaults before their quantities` |
| A quantity of 0, 1000 or 1.5; a key that is not an id; a quantity for an option that is not in `defaultOptionIds` (K14a) | `400 · foundation.validation_failed` at `offers[i].defaultQuantities.<optionId>`, nothing judged | `test/e2e/catalog-offer-default-quantities.e2e-spec.ts` › `refuses %s at the door with a 400`; `src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts` › `reads the quantity of a default from defaultQuantities, one to 999 per option, and none when it is absent` |
| A lapsed plan changes the quantity of a default, lowering it included (K14a decision: never a shrinking edit) | the plan is asked and the write is refused; nothing is written | `src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts` › `writes a default quantity that is the only change of an item list, asking about the plan because it is never a shrinking edit`; `src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts` › `compares the quantities sent with the stored ones, so repeating the stored quantity changes nothing and lowering it is a write` |
| An option limited in an offer (`optionLimits` by option id, 0 withholds it); the same list again; the limits lifted; a body without `optionLimits` (K14b) | `200` with `optionLimits` on the item and on the offers of the group, only while the offer limits an option; the identical list writes nothing and keeps the tag; changed or lifted limits move the tag; the record shows `;limits=o1=1+o2=0` after the status and the defaults | `test/e2e/catalog-offer-option-limits.e2e-spec.ts` › `stores and answers the limits on the item and on the group offers, replays them without a write and moves the item tag when they change`; `src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts` › `writes the limits with the offer and records them in the audit of the list, sorted, after the status`; `src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts` › `answers the item as stored and records nothing when the limits sent are the stored ones, however they are ordered`; `src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts` › `writes the removal of every limit, so a list sent without limits offers its options fully again`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `compares the limits of two offers as a set and never mistakes an empty one for a change`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `writes the limits of an offer after its defaults, sorted, only when it has some` |
| A limit equal to or above the maximum of its option; a limit on an option the group does not have (K14b) | `422 · catalog.option_limits_invalid` pointing at `offers[i].optionLimits.<optionId>` with a message that names the option and its own `maxQuantity`; the item tag unchanged; a limit may only lower an option | `test/e2e/catalog-offer-option-limits.e2e-spec.ts` › `refuses %s with a 422 that names the option and its maximum and writes nothing`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `refuses %s`; `src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts` › `refuses a limit %s, naming the option and its maximum, and writes nothing`; `src/modules/catalog/domain/modifiers/offer-list-judgement.spec.ts` › `points at the limited option of an offer it refuses, with the real maximum of that option`; `src/modules/catalog/presentation/failures/offer-list-refusal-failure.spec.ts` › `names the option and its real maximum when a limit would not lower it` |
| A limit of -1, 1000 or 1.5; a key that is not an id; a field next to `optionLimits` that the body does not know (K14b) | `400 · foundation.validation_failed` at `offers[i].optionLimits.<optionId>`, nothing judged | `test/e2e/catalog-offer-option-limits.e2e-spec.ts` › `refuses %s at the door with a 400`; `src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts` › `reads the limit of an option from optionLimits, zero to 999 per option, and none when it is absent`; `src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts` › `refuses an unknown field next to optionLimits, so a misspelt limit is never silently dropped` |
| A lapsed plan changes only the limits of an offer, or lifts them (K14b: never a shrinking edit) | the plan is asked and the write is refused; nothing is written | `src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts` › `asks about the plan for a limit that is the only change of an item list, because it is never a shrinking edit`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `counts a limit-only edit as a change that needs the plan, never as a shrinking one` |
| A minimum above what the limited options can reach; every option limited to 0 on a required group; a default limited to 0; a default quantity above its limit (K14b) | `422 · catalog.selection_rule_invalid`; `422 · catalog.default_options_invalid`; `422 · catalog.default_quantities_invalid`; the limits are judged first and the rule against the effective maximum of each option; a default quantity equal to its limit is accepted | `test/e2e/catalog-offer-option-limits.e2e-spec.ts` › `judges the interplay with the minimum and the defaults against the effective maximum of each option`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `reaches a minimum through the effective maximum of the options, not through their own`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `counts nothing for an option limited to zero, so a group whose options are all limited cannot be required`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `refuses a default that is limited to zero in this offer`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `judges a default quantity against the effective maximum of its option`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `judges the limits before the selection rule`; `src/modules/catalog/application/items/offer-modifier-groups-limits.use-case.spec.ts` › `refuses a minimum the limited options can no longer reach and a default that is limited to zero` |
| A stored limit that became moot because its option was lowered, or whose option left, sent back unchanged with another change of the rule; a limit that changed (K14b) | an unchanged limit is never judged again, so reading an offer and sending it back does not fail; a changed one is judged against the option as it is now | `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `keeps a limit that became moot or whose option left unjudged while the rest of the rule changes`; `src/modules/catalog/domain/modifiers/modifier-offer-limits.spec.ts` › `judges again a kept offer whose limits changed, but not one whose limits are the same`; `src/modules/catalog/domain/modifiers/option-limits.spec.ts` › `leaves unjudged a limit that is already stored for the same option, even when it became moot or its option left` |
| A variant override with limits of its own beside the limits of the item (K14b) | each list keeps its own limits; the item read answers `optionLimits` on the item offer and on the override of the variant, and a read of an offer without limits has no `optionLimits` key | `test/e2e/catalog-offer-option-limits.e2e-spec.ts` › `gives each variant override its own limits, independent of the ones of the item`; `src/modules/catalog/presentation/dtos/items/item-response.spec.ts` › `answers optionLimits on an offer only when it limits an option, on the item and on a variant override` |
| A group the item already offers, deactivated since, kept with its rule; a new group added beside it; an inactive item | `200`; no status of the item or of the group changes | `test/e2e/catalog-modifier-offers.e2e-spec.ts` › `keeps a group the item already offers after it was deactivated, judges only new groups and changed rules, and never changes the status of the item or the group`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `keeps a group already offered with the same rule even when it is inactive now or lost its active options`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `judges again the rule of a kept group whose rule changed, but never its status` |
| A lapsed plan detaches one of two groups, then every group, and repeats a list; it attaches a group, makes a rule stricter or reorders | `200` with one record per real change, none for the repeat, never asking about the plan; `403 · platform.capability_not_entitled` with one denied record each, asked after the item lock | `test/e2e/catalog-modifiers-access.e2e-spec.ts` › `lets an organization whose plan lost the catalog detach one of two groups and then every group from an item, and repeat that list, while attaching a group or changing a rule answers the entitlement code with one denied record`; `src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts` › `let a lapsed plan drop groups from an item with the plain permission only, never asking about the plan`; `src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts` › `ask about the plan after the item lock when an offer adds a group, and record the entitlement refusal without writing`; `src/modules/catalog/domain/modifiers/modifier-offer.spec.ts` › `calls an offer shrinking only when it drops groups and keeps every other group with its exact rule, whatever its order` |
| An archived item; a group or an item of another organization or unknown | `422 · catalog.item_archived`; `404 · catalog.not_found`; nothing written | `test/e2e/catalog-modifier-offers.e2e-spec.ts` › `refuses an archived item, and an unknown or foreign group or item with 404, writing nothing` |
| The restaurant journey: cooking point and extras offered on a burger, an option edited and another deactivated | every read carries the groups in order with their options; the tag changes twice and the first one stops matching | `test/e2e/catalog-modifiers-journey.e2e-spec.ts` › `creates the cooking point and the extras, offers them on a burger, reads the burger with its groups, sees its tag change after an option edit and a deactivation, and the old tag stop matching` |
| A required offer retired by the command (K9b) and another suspended in SQL (the route writes no suspension yet) | the retired one is ignored: the item stays in the till lists, the scan and `resolve`, without that group; the suspended one hides the item with `required_modifier_unsatisfiable` | `test/e2e/catalog-till-parity.e2e-spec.ts` › `keeps an item sellable after its required group is retired, but not after it is suspended`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `ignores a retired offer before judging a required group, and keeps a suspended one as required but unavailable` |
| A retired offer, in SQL; the group offered again on that item | absent from the item read and from the items that offer the group; `200`, the group offered again; the status allows only `active`, `inactive` and `retired`, and a retired row keeps its link, so a second row for the same item or variant and group is refused | `test/e2e/catalog-modifier-group-offers.e2e-spec.ts` › `leaves a retired offer out of the item read and out of the items that offer the group, and lets the item offer the group again`; `database/tests/contracts/catalog-offer-retired-contracts.ts` › `NINAKU_CATALOG_OFFER_RETIRED_OK` |
| The maximum of an offered group edited; the offers reordered (K9b) | `200`; each offer keeps its id, updated in place with its new rule or place; the record lists each offer as `offerId:groupId:min:max:status`; no statement deletes an offer or rewrites its identity, scope or group | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `keeps the offer id when its maximum changes`; `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `keeps every offer id when the offers are reordered`; `src/modules/catalog/infrastructure/modifiers/item-offer.queries.spec.ts` › `never delete an offer of the %s scope: they retire the links left out and update the kept and revived ones in place`; `src/modules/catalog/infrastructure/modifiers/item-offer.queries.spec.ts` › `the %s never rewrites the identity, scope or group of an offer` |
| A group added beside one already offered (K9b) | `200`; only the new group gets a new offer id | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `gives a new id only to a newly offered group` |
| A group left out of the list, then offered again (K9b) | `200`; the offer is `retired` with the same id and no defaults, out of the item read, and the record ends with it as `retired`; offered again, the same id returns to `active` with the rules sent, with no second row | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `revives the same offer id when a retired group is offered again` |
| An offer a Pricing modifier price references, edited then retired; an offer a Sales-shaped row references, retired (K9b) | both `PUT`s succeed; the price row still joins its offer, now retired with the edited rule; the sale row stays readable with its offer retired; the three foreign keys of Pricing, Commerce and Sales to the offers are all `NO ACTION` | `src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts` › `edits and retires an offer that a pricing row references without breaking the reference`; `src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts` › `retires an offer that a sale row references and keeps the row readable` |
| The audit record fails after the offers were written (K9b) | `500`; one transaction: the offers, their ids, the item tag and the audit trail are unchanged | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `leaves the offers, their ids, the audit trail, the announced changes and the version untouched when the record fails after the write` |
| The runtime role deletes an offer, one nothing references or a retired one, or reaches it through a `DELETE` of its item, variant or group (K9c) | `42501`; the offer keeps its row and id; retiring, reviving and editing it in place still work, and the defaults of a retired offer are still deleted | `database/tests/contracts/catalog-offers-never-deleted-contracts.ts` › `NINAKU_CATALOG_OFFERS_NEVER_DELETED_OK` |
| The published shape of an offered group | exactly the stored values, accepted by the strict schema | `src/modules/catalog/presentation/dtos/items/item-response.spec.ts` › `answers the item, its variants, its categories and its modifier groups exactly as stored, in the published shape` |
| `variantOverrides` of `PUT /catalog/items/{itemId}/modifier-groups` (K9g, the variant route merged into it by API v7 PR 5): an override of an item group and of a group the item does not offer; its rule changed and reordered; left out; listed again with `optedOut: true` | `200` with the item, its tag equal to the read's; the override keeps its offer id in place; left out it is `retired`; listed again the same id returns as `inactive` with `0`, `1` and no defaults whatever was sent; the item-level offer never changes; the record names the variant (`catalog.item.variant`) and lists `offerId:groupId:min:max:status` (`catalog.item.variant.modifier_groups`) | `test/e2e/catalog-variant-offer-route.e2e-spec.ts` › `overrides and opts out per variant, keeps the override id while its rule or order changes, retires it when left out and revives the same id` |
| One `PUT /catalog/items/{itemId}/modifier-groups` with the item list and two variant lists; then the item list alone; then one variant with an empty list; that same body again | `200` with the item and the read's tag, one `catalog.item.modifier_groups_set` per changed list (three); without `variantOverrides` every variant keeps its overrides; the variant sent empty follows the item again while the variant not sent keeps its overrides; the repeat records nothing | `test/e2e/catalog-variant-offer-route.e2e-spec.ts` › `sets the item list and the lists of the variants sent in one call, one record per changed list, leaves a variant not sent and every variant without variantOverrides untouched, and clears a variant sent with an empty list` |
| A variant the item does not have, a rule no option can reach inside a variant list, an archived variant, eleven variant lists | `404 · catalog.not_found` at `variantOverrides[1].variantId`; `422 · catalog.selection_rule_invalid` at `variantOverrides[0].offers[0].minSelections`; `422 · catalog.variant_archived` at `variantOverrides[0].variantId`; `400`; nothing written | `test/e2e/catalog-variant-offer-route.e2e-spec.ts` › `points at a variant the item does not have, an archived variant and a refused rule inside a variant list, writing nothing: the item list, every variant override and the audit trail stay as they were`; `src/modules/catalog/presentation/dtos/modifiers/offer-modifier-groups-request.schema.spec.ts` › `takes at most 10 variant lists per request` |
| The same variant list again; a stale `If-Match` | `200` with the same tag and no record; `412 · foundation.precondition_failed`, nothing written | `test/e2e/catalog-variant-offer-route.e2e-spec.ts` › `answers the same list with 200 and no record, refuses a stale If-Match with 412 writing nothing, and tags the answer like the item read` |
| An archived variant; a variant of another item; an unknown variant; an archived item (K9g) | `422 · catalog.variant_archived`; `404 · catalog.not_found` for both; `422 · catalog.item_archived`; nothing written | `test/e2e/catalog-variant-offer-route.e2e-spec.ts` › `refuses an archived variant, a variant of another item, an unknown one and an archived item, writing nothing` |
| The item route suspends an offer (`suspended: true`), then resumes it (K9g) | the same offer id becomes `inactive`, the item answers `suspended: true`; resumed, the same id is `active` again | `test/e2e/catalog-variant-offer-route.e2e-spec.ts` › `suspends an item offer in place and resumes the same id through the item route` |
| The audit record fails after the variant offers were written (K9g) | `500`; one transaction: the variant offers, their ids, the item tag and the trail are unchanged | `test/e2e/catalog-variant-offer-route.e2e-spec.ts` › `leaves the variant offers, their ids, the item tag and the trail untouched when the record fails after the write` |
| The variant route without a session | `401 · identity.session_required` before any lookup | `test/e2e/catalog-variant-offer-access.e2e-spec.ts` › `refuses a request without a session before anything else` |
| A cashier, a kitchen member, a member without a role, a manager of one unit, on existing and unknown targets (K9g) | the plain `403 · catalog.action_forbidden`, nothing about the plan, one denied record each, nothing written | `test/e2e/catalog-variant-offer-access.e2e-spec.ts` › `refuses a cashier, a kitchen member, a member without a role and a manager of one unit with the plain denial on existing and unknown targets, one denied record each and nothing written` |
| An item, a variant or a group of another organization on the variant route (K9g) | `404 · catalog.not_found`, like unknown ids; nothing changes there | `test/e2e/catalog-variant-offer-access.e2e-spec.ts` › `answers an item, a variant and a group of another organization like unknown ones and changes nothing there` |
| A lapsed plan: changes an override; opts a variant out, drops its overrides, suspends an unchanged item offer; then opts back in or resumes (K9g) | `403 · platform.capability_not_entitled` with one denied record for the change; `200` without asking about the plan; `403` with one denied record each, the opt-out and the suspension still stored | `test/e2e/catalog-variant-offer-access.e2e-spec.ts` › `lets a lapsed plan opt a variant out, drop its overrides and suspend an unchanged item offer, while a changed override, an opt-in from an opt-out or a resume answers the entitlement code with one denied record and changes nothing` |
| The plan rule of withheld offers (K9g) | an opt-out or a drop on a variant is never gated; an override and an opt-in are; a suspension of an unchanged item rule is not, a resume or a suspension with a new rule is; a pure reorder stays gated | `src/modules/catalog/domain/modifiers/modifier-offer-withholding.spec.ts` › `never gates an opt-out or a retirement on a variant, but gates an override and an opt-in`; `src/modules/catalog/domain/modifiers/modifier-offer-withholding.spec.ts` › `never gates suspending an item offer whose rule is unchanged, but gates resuming it and suspending a changed rule` |
| An opt-out written by the variant route on a required item-level group (K9g, after K9g-0) | `admit` answers no offer for that variant and Sales stores its line with no selection, refusing a selection through the item offer; the inheriting variant keeps the minimum: `admit` answers `selection_below_minimum` and Sales refuses an empty line but stores the item offer | `test/e2e/catalog-variant-offer-sales-parity.e2e-spec.ts` › `lets an opted-out variant sell with no selection in Catalog and in Sales, refuses the item offer on it, and keeps the item minimum on the other variant` |
| A group deactivated before it was offered: suspended on the item, then resumed; opted out on a variant, then turned into an override (K9g review fix) | the suspension and the opt-out are stored (`200`, never need an active group); the resume and the override answer `422 · catalog.modifier_group_not_active` and the row stays `inactive`, so no request sequence makes an inactive group an active offer | `test/e2e/catalog-variant-offer-activation.e2e-spec.ts` › `refuses to resume on the item a suspended offer of a group deactivated before it was offered, and keeps it suspended`; `test/e2e/catalog-variant-offer-activation.e2e-spec.ts` › `refuses to turn into an override an opt-out of a group deactivated before, and keeps the opt-out` |
| The rule of every transition to `active` (K9g review fix) | a new offer, a resume from a suspension and an opt-in from an opt-out judge the group status and the rule again, even with the same rule; an active offer kept active with its rule is never judged again; a suspension or an opt-out never needs an active group | `src/modules/catalog/domain/modifiers/modifier-offer-withholding.spec.ts` › `judges the group status and the rule again whenever an offer becomes active: resumed from a suspension or an opt-out, even with the same rule`; `src/modules/catalog/domain/modifiers/modifier-offer-withholding.spec.ts` › `never lets a group that is not active become an active offer in two requests, suspended or opted out first` |
| An active item offer suspended and an active variant override opted out in place, then resumed (K9g) | the same rows persist `inactive`, then `active` again, ids unchanged; each scope upserts by the columns and predicate of its own partial unique index | `src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts` › `updates in place an active item offer to suspended and an active variant override to opted out, persisting and reading back inactive, and back to active when resumed`; `src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts` › `upserts each scope by the columns and predicate of its partial unique index` |
| The order of the offers command (K9g, one route since API v7 PR 5) | permission, catalog version, item lock, every variant sent judged (unknown `404`, archived `422`, pointing at `variantOverrides[i].variantId`), the plan only for a gated change, the groups of every changed list shared once in id order, each changed list written with one record, the read; a list sent as stored is neither judged nor written | `src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts` › `writes an override after the permission, the version and the item, asking about the plan, and records the variant beside its lists`; `src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts` › `writes the item list and every changed variant list in one transaction, share-locking their groups once in id order, leaving unchanged and unmentioned lists untouched, one record per changed list`; `src/modules/catalog/application/items/offer-modifier-groups.use-case.spec.ts` › `points at the offer it refuses inside the list it belongs to, writing nothing`; `src/modules/catalog/domain/modifiers/offer-list-judgement.spec.ts` › `points at the field of every offer it refuses, under the list it belongs to`; `src/modules/catalog/domain/modifiers/offer-list-judgement.spec.ts` › `points at a variant the item does not have and at an archived one` |
| The statements of each scope (K9g) | the item statements touch only `item_variant_id IS NULL`, the variant ones only their variant, each upserts by its own partial unique index; none deletes an offer or rewrites its identity | `src/modules/catalog/infrastructure/modifiers/item-offer.queries.spec.ts` › `confine every statement of a scope to that scope` |
| Writing a variant scope in PostgreSQL (K9g) | ids kept in place, retired, revived, an opt-out stored `inactive`, the item-level rows and their defaults untouched | `src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts` › `writes the offers of one variant in place, retires and revives them, writes an opt-out as inactive, and never touches the item-level offers`; `src/modules/catalog/infrastructure/modifiers/variant-offer-writes.integration-spec.ts` › `clears and writes the defaults of the variant offers only` |

***

## Barcodes

### C11 — Set the barcodes of a variant

`PUT .../variants/{variantId}/barcodes` (API v7 PR 4, migration `0103`)
replaces the whole set under a required `If-Match` on the item tag. A
barcode keeps its id; one left out is archived, never deleted; `suspended`
keeps it inactive. A set that only suspends, archives or drops the primary
never asks the plan. The value is unique among the active barcodes of the
catalog (migration `0079`).

| Case | Expected answer | Proving test |
|---|---|---|
| Set two barcodes, one suspended; replay the same set in another order; the same value on another item; another organization | `200` with the set and the item tag `GET` repeats, one `catalog.item.barcodes_set` with the live set before and after; the same tag, nothing recorded; `409 · catalog.barcode_taken` at `barcodes[1].barcode` with no constraint name; `200` elsewhere | `test/e2e/catalog-barcodes.e2e-spec.ts` › `sets the barcodes of a variant, answers the set with the item tag, records one record with the live set before and after, refuses the same value on another item and lets another organization use it` |
| A barcode left out; its value on another variant; brought back while taken; brought back once freed | archived, absent from the set and the item read, the row kept; `200`; `409` at `barcodes[0].barcode`; `200` with the same id, `inactive` when sent suspended | `test/e2e/catalog-barcodes.e2e-spec.ts` › `archives a barcode left out, never deleting it, frees its value and brings it back with the same id when its value is sent again`; `src/modules/catalog/domain/items/barcode-set.spec.ts` › `brings an archived barcode back with its own id when its value is sent again, suspended as inactive` |
| The primary swapped between two existing barcodes; a new barcode made primary while the former one is demoted | `200` each with exactly one primary: rows leaving active are written first, then rows staying or becoming active with the primary cleared, then the one primary, then the new rows, so the non-deferrable one-primary index never sees two | `test/e2e/catalog-barcodes.e2e-spec.ts` › `swaps the primary between two existing barcodes, and makes a new barcode primary while the former primary is demoted, in one save each` |
| Twenty barcodes replaced by twenty others; 21 | `200`, the archived ones do not count; `400 · foundation.validation_failed` | `test/e2e/catalog-barcodes.e2e-spec.ts` › `does not count archived barcodes toward the 20 of a variant and refuses 21 at the schema`; `src/modules/catalog/application/items/set-variant-barcodes.use-case.spec.ts` › `refuses 21 barcodes, a repeated value or two primaries before opening a transaction` |
| A misfit symbology, a changed value, an unknown id, a suspended primary; a repeated GTIN, two primaries, a blank value | `422 · catalog.barcode_invalid`, `422 · catalog.barcode_value_fixed`, `404 · catalog.not_found`, `422 · catalog.barcode_not_active`, each at its entry; `400` at `barcodes[i].barcode` or `.isPrimary`; nothing written | `test/e2e/catalog-barcodes.e2e-spec.ts` › `points at each wrong entry: a misfit symbology, a changed value, an unknown id, a suspended primary, a repeated GTIN and two primaries`; `src/modules/catalog/domain/items/barcode-set.spec.ts` › `points at each unknown id, changed value, misfit value and suspended primary`; `src/modules/catalog/presentation/dtos/items/variant-barcodes.schemas.spec.ts` › `points at the repeated GTIN, the repeated id and the second primary`; `src/modules/catalog/domain/items/barcode.spec.ts` › `refuses %s as %s` |
| A lapsed plan suspends or archives; brings one back; an archived variant or item | `200` without asking the plan; `403 · platform.capability_not_entitled`; `422 · catalog.variant_archived`, `422 · catalog.item_archived` | `test/e2e/catalog-barcodes.e2e-spec.ts` › `suspends and archives on a lapsed plan, refuses bringing a barcode back there, and refuses an archived variant or item`; `src/modules/catalog/application/items/set-variant-barcodes.use-case.spec.ts` › `suspends and archives barcodes on a lapsed plan without asking the plan, and refuses bringing one back` |
| Archiving a variant | its active barcodes are deactivated in the same command with one `catalog.item.barcode_deactivated` record; the value can be added to another variant that the till and the back office then find; archiving again writes nothing | `test/e2e/catalog-barcode-lookup.e2e-spec.ts` › `archiving a variant releases its active barcodes in the same command, so the value can be added to another variant that the till and the back office then find, and archiving again writes nothing` |
| Archiving an item; restoring it | the active barcodes of all its variants are deactivated and recorded once; restoration reactivates none; the values can then be set on another item | `test/e2e/catalog-barcodes.e2e-spec.ts` › `archiving an item releases the active barcodes of all its variants, and restoring it reactivates none` |
| One GTIN in two encodings | a UPC-A is stored as its 13-digit EAN-13 form; the EAN-13 form on another variant `409 · catalog.barcode_taken`; the till and the back office find it by the 12, 13 or 14-digit form | `test/e2e/catalog-barcode-lookup.e2e-spec.ts` › `stores a UPC-A as its 13-digit EAN-13 form, refuses the same GTIN in the other encoding with catalog.barcode_taken, and finds the variant by either form at the till and in the back office`; `src/modules/catalog/domain/items/barcode.spec.ts` › `stores and looks up %s as the GTIN %s` |
| Value unique among active barcodes only, per organization and catalog | two retired barcodes may share a value; a second active one is a unique violation | `database/tests/contracts/catalog-barcode-contracts.ts` › `a second active barcode with the same value in one catalog is a unique violation` |
| An archived barcode in SQL | the status check accepts `archived` and refuses any other status; its value can be active on another variant; only the active row is live for a scan; `catalog.resolve_barcode` answers the active variant of a value an archived barcode shares and nothing for a value only archived barcodes hold; the one-primary index reads active rows only | `database/tests/contracts/catalog-barcode-contracts.ts` › `the status check accepts an archived barcode`; `database/tests/contracts/catalog-barcode-contracts.ts` › `the value of an archived barcode can be active on another variant`; `database/tests/contracts/catalog-barcode-contracts.ts` › `the barcode lookup resolves a value shared by an archived and an active barcode to the active variant`; `database/tests/contracts/catalog-barcode-contracts.ts` › `the barcode lookup finds nothing for a value only an archived barcode holds` |
| Two saves of one set with the same tag; no `If-Match` or `*` | one `200`, one `412 · foundation.precondition_failed`; `428 · precondition.required` | `test/e2e/catalog-barcodes.e2e-spec.ts` › `serializes two saves of the same set: the second, sent with the same tag, answers 412`; `test/e2e/catalog-required-preconditions.e2e-spec.ts` › `$route answers 428 without If-Match or with *, 412 with a stale tag and 200 with the current one, and a replayed list with its new tag answers 200 recording nothing` |
| The plan, the change check and the audit | the item and version locked first, then the variant rows read, the values brought to life checked against other variants, one write, one record whose summaries come from the key the change check compares; an unchanged live set writes and records nothing | `src/modules/catalog/application/items/set-variant-barcodes.use-case.spec.ts` › `locks the item, reads the variant barcodes, checks the values it brings to life, writes the changes and records one record with the live set before and after`; `src/modules/catalog/application/items/set-variant-barcodes.use-case.spec.ts` › `answers the stored item when the live set does not change, writing and recording nothing`; `src/modules/catalog/domain/items/barcode-set.spec.ts` › `summarizes the live barcodes with every compared field, in id order, ignoring archived ones` |
| Statement budget | 15 statements to add, suspend and archive at once, the same for 1 and 10 barcodes (23 before the one-statement item read) | `test/e2e/statement-budget.e2e-spec.ts` › `replaces the barcode set of a variant, adding, suspending and archiving at once, in the same statements whatever the number of barcodes` |
| What the client sees for each refused entry | the catalog code, its status, `meta.errors[]` with the field and a message that says how to fix it; only the leading kind is listed | `src/modules/catalog/presentation/failures/barcode-set-refusal-failure.spec.ts` › `answers %s with %i and %s, pointing meta.errors at the entry with a message that says how to fix it`; `src/modules/catalog/presentation/failures/barcode-set-refusal-failure.spec.ts` › `answers the kind that comes first when several are refused, listing only its entries` |

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

`GET /catalog/outlets/{outletId}/barcode-matches?barcode=`, needs
`catalog.sellables.view` at the outlet; and the back-office
`GET /catalog/barcode-matches?barcode=` with `catalog.items.view`. Both
`200`, `Cache-Control: private, no-cache`, no ETag, nothing recorded.

| Case | Expected answer | Proving test |
|---|---|---|
| A cashier scans a listed variant; unknown value, suspended or archived barcode, inactive variant or item, another organization's code | `200` with the variant and quantity policy; `data: null` for every other case | `test/e2e/catalog-barcode-lookup.e2e-spec.ts` › `resolves a scanned barcode at an outlet to its sellable variant for a cashier, records nothing, and answers null for an unknown value, a suspended or archived barcode, an inactive variant, an inactive item and a barcode of another organization` |
| An outlet no unit operates today; kitchen and a cashier of another outlet; an unknown outlet | `data: null`; the same plain `403` for existing and unknown outlets; `404 · organization.structure_not_found` only for an organization-wide holder | `test/e2e/catalog-barcode-lookup.e2e-spec.ts` › `answers null at an outlet no business unit operates today, and refuses a kitchen member and a cashier of another outlet with the same plain denial for an existing and an unknown outlet, while only an organization-wide holder learns that an outlet is unknown`; `src/modules/catalog/application/barcodes/look-up-barcode.use-case.spec.ts` › `checks catalog.sellables.view at the outlet before any lookup, so a refused caller never reaches the catalog` |
| The back office finds a barcode whatever the variant status; unknown value; a cashier | `200` with item and variant; `data: null`; `403 · catalog.action_forbidden` | `test/e2e/catalog-barcode-lookup.e2e-spec.ts` › `finds the item and the variant of an active barcode in the back office whatever their status, answers null for an unknown or foreign value, and keeps it from a cashier` |
| A missing, blank, too long or NUL value; an unknown parameter | `400 · foundation.validation_failed` | `test/e2e/catalog-barcode-lookup.e2e-spec.ts` › `refuses a missing, blank, too long or NUL-holding value and an unknown parameter with 400 at the till and in the back office` |
| An item whose required group lost its last available option; the option reactivated | `data: null` like an unknown code, as the till lists leave it out; resolved again | `test/e2e/catalog-till-parity.e2e-spec.ts` › `answers null to the scan of an item whose required group lost its last available option, exactly like the lists, and resolves it again once an option is reactivated` |
| The scan and the sellables read on one table of cases | the same verdict for listed, delisted, inactive variant, satisfiable and unsatisfiable required group, inactive group, option, target variant and target item, and the contract agrees | `test/e2e/catalog-till-parity.e2e-spec.ts` › `gives the scan, the sellables read and the sellable contract the same verdict for a listed and a delisted item, an inactive variant, a satisfiable and an unsatisfiable required group, and an inactive group, option, target variant or target item` |
| Per-variant offers (K9f): a variant whose own required offer has no available option, and one that inherits a required item-level group with none, left out; a variant that opts out of that group, or overrides it, resolved | `data: null` for the unconfigurable variant only, the other variants of the item still scan, as the lists and the contract agree | `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `judge every variant and list its offers through the one effective-offer fragment: the till, the search, the scan, resolve and admit` |
| Two sellable matches at one outlet | `409 · catalog.barcode_ambiguous` | `src/modules/catalog/application/barcodes/look-up-barcode.use-case.spec.ts` › `answers null without a match, an unknown outlet as structure not found and two matches as ambiguous` |
| Two active barcodes with one value in the back office | `409 · catalog.barcode_ambiguous`, ordered and bounded like the till | `src/modules/catalog/application/barcodes/look-up-barcode.use-case.spec.ts` › `answers the single match, null without one, and two matches as ambiguous like the till` |
| The scan issues an exact number of statements | 7, guards, `BEGIN` and `COMMIT` included | `test/e2e/statement-budget.e2e-spec.ts` › `$route issues exactly $statements SQL statements` |
| A retail journey: matrix, barcodes, scan, edit, deactivation | the till answers null for the deactivated variant while the back office still finds it | `test/e2e/catalog-variants-journey.e2e-spec.ts` › `a shop owner builds a shirt as a size by colour matrix, labels two variants, a cashier scans one at the till, an edit changes the item tag, and a deactivated variant still resolves in the back office while the till answers null` |

## Point-of-sale reads

### C17 — Read what an outlet sells

`GET /catalog/outlets/{outletId}/sellable-items`, needs
`catalog.sellables.view` at the outlet, decided before the outlet is looked
up; never gated by the plan; records nothing. `200` with a strong ETag
`c<catalog version>-s<structure version>-<business unit or none>-d<delivery fingerprint>`
(the fingerprint since A6, AC4),
`Cache-Control: private, no-cache` and `meta: { catalogVersion, truncated, omittedItems, media }`; `304` on a
matching `If-None-Match`, decided before any item is read.

| Case | Expected answer | Proving test |
|---|---|---|
| A default above one unit on the till read, then the maximum of its option lowered to one (K14a) | the offer answers `defaultQuantities` only while some default is above one unit; the quantity is never above what the option still allows; an offer without quantities has no `defaultQuantities` key | `test/e2e/catalog-offer-default-quantities.e2e-spec.ts` › `shows the till the quantity of a default only when it is above one and never above what the option still allows`; `src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts` › `answers defaultQuantities on an offer and on an override only when a default starts above one unit` |
| The till read of an offer with limited options, and of one without (K14b) | the offer carries `optionLimits` (option id to its limit, 0 meaning the option is not offered there) only for the available options a limit really lowers; an offer with none has no `optionLimits` key; the options of a group stay listed once with their own `maxQuantity` | `test/e2e/catalog-offer-option-limits.e2e-spec.ts` › `carries the limits of each offer on the till and shows nothing when no option is limited`; `src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts` › `answers optionLimits on an offer and on an override only when it limits an option, and parses them as the wire contract`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `answers the limits of an offer only for the available options it really lowers, so the till carries nothing when nothing is limited` |
| A required offer whose limited options can no longer reach its minimum, stored in SQL (K14b) | the variant is not sold: the till leaves the item out, `admit` refuses it with `required_modifier_unsatisfiable`; one keyed probe per offer, and one per option only in an offer that has limits, lowers the units the offer can reach | `test/e2e/catalog-offer-option-limits.e2e-spec.ts` › `stops selling a variant whose required offer its limited options can no longer reach, in the till and in admit alike`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `lowers the units an offer can reach by the limits of the offer, one keyed probe per offer and one per option only in an offer that has limits, ignoring an option without a limit`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `reads the limits of an offer only when its group has some, asking once per statement, as a set, which groups of the organization do, one row each`; `src/modules/catalog/infrastructure/items/item-read.queries.spec.ts` › `probes the limits of an offer only when its group has some, asking once per group of the item with a one-row probe` |
| A sellable item with a named variant and the default one | name order, active variants with the quantity policy, `categoryId`, no codes, SKUs or prices; `meta.catalogVersion` equals the stored version | `test/e2e/catalog-till-sellables.e2e-spec.ts` › `answers the sellable items in name order with their active variants and quantity policy, a strong tag and the catalog version it was read at` |
| An inactive item, an archived item, an inactive variant of a sold item | the first two absent; the item stays with its other variants | `test/e2e/catalog-till-sellables.e2e-spec.ts` › `leaves out an inactive item, an archived item and an inactive variant, and keeps an item while one variant stays active` |
| A branch below an inactive ancestor; an item with no category | the branch leaves the tree, its descendants keep their status, its items sell with `categoryId: null`; reactivating the ancestor brings the branch back | `test/e2e/catalog-till-sellables.e2e-spec.ts` › `hides a category branch below an inactive ancestor without touching its descendants, keeps its items selling with no category, and sells an item that has no category at all` |
| Offered groups and their options | only available options (an inactive option, an inactive group, an inactive target variant are unavailable); defaults exactly as the offer; an optional group without options leaves the item; an item whose required rule cannot be met is omitted and counted in `meta.omittedItems` (no id or name) | `test/e2e/catalog-till-sellables.e2e-spec.ts` › `offers only available options, preselects exactly the defaults of the offer, drops an optional group that lost its group or options, and leaves out an item whose required group can no longer be satisfied, counting it in meta.omittedItems` |
| An extra whose target variant is active but whose item is archived or inactive | the option leaves the group and its preselection, the dish keeps selling; a required group left without options removes the item from the lists, the scan and the contract alike | `test/e2e/catalog-till-sellables.e2e-spec.ts` › `drops an extra whose target variant is active but whose item is archived or inactive, and its preselection, while the dish keeps selling` |
| The assortment of the unit operating each of two outlets | no row and `all` sell everything but exclusions; `selected` only listings; `none` and an inactive unit nothing | `test/e2e/catalog-till-freshness.e2e-spec.ts` › `follows the assortment of the unit that operates each outlet: no row and all sell everything but an exclusion, selected sells only listings, none and an inactive unit sell nothing` |
| `If-None-Match` with the tag and its weak form; after a catalog write; after a write of another organization | `304`, `304`; `200` with a new tag and the next catalog version; still `304` | `test/e2e/catalog-till-freshness.e2e-spec.ts` › `answers 304 to its own tag and to its weak form while nothing changed, 200 with a new tag and a newer catalog version after a catalog write, and ignores writes of another organization` |
| 2,001 sellable items | 2,000 items and `meta.truncated: true`; the search pages through all 2,001 under one catalog version | `test/e2e/catalog-till-freshness.e2e-spec.ts` › `answers at most 2,000 items with truncated true, and the search pages through every one of them` |
| Kitchen, bar, `catalog.items.view` alone, no role | the same plain `403` at an existing, unknown, closed and foreign outlet; nothing recorded | `test/e2e/catalog-till-access.e2e-spec.ts` › `refuses a kitchen and a bar member, a holder of catalog.items.view alone and a member without a role the same plain denial on both till reads at an existing, an unknown, a closed and a foreign outlet, and records nothing` |
| A cashier and a waiter scoped to their outlet | `200` there; the same plain `403` at another outlet of the same unit, an unknown, a closed and a foreign outlet | `test/e2e/catalog-till-access.e2e-spec.ts` › `lets a cashier and a waiter scoped to their outlet read and search it, and refuses them another outlet of the same business unit, an unknown, a closed and a foreign outlet with the same plain denial` |
| An unknown or foreign outlet for an organization-wide holder | `404 · organization.structure_not_found` | `test/e2e/catalog-till-access.e2e-spec.ts` › `tells only an organization-wide holder that an outlet is unknown or belongs to another organization, with 404 organization.structure_not_found on both reads` |
| A lapsed plan | `200` for the owner and a cashier | `test/e2e/catalog-till-access.e2e-spec.ts` › `keeps both till reads open on a lapsed plan for the owner and a cashier` |
| An outlet no unit operates today | `200` with `businessUnitId: null`, empty lists, `meta.truncated: false`, `meta.omittedItems: 0`, a tag ending in `-none`; a manager anchored at a unit gets the plain `403` | `test/e2e/catalog-till-access.e2e-spec.ts` › `answers an outlet no business unit operates today with an empty read and an empty page to a caller the permission admits, and the plain denial to a manager anchored at a business unit` |
| A structure write: an outlet assigned to a unit, the unit renamed | `200` with a new tag to the previous `If-None-Match`, the catalog version unchanged | `test/e2e/catalog-till-freshness.e2e-spec.ts` › `changes its tag after a structure write: an outlet assigned to a business unit, and the unit renamed, answer 200 to the previous tag while the catalog version stays the same` |
| The outlet moves to another business unit between the pre-check and the read of a refresh (race) | one coherent state: the unit, the items and the structure and unit segments of the tag come from the statement that read the items, equal to the state before or the state after the move, never the items of one with the tag of the other; the pre-check tag is never answered `304` once the outlet moved | `test/e2e/catalog-till-reassignment-race.e2e-spec.ts` › `answers either the state before the reassignment or the state after it, never the items and the unit of one with the tag of the other`; `test/e2e/catalog-till-reassignment-race.e2e-spec.ts` › `answers the state after the reassignment when it committed before the outlet was read`; `test/e2e/catalog-till-reassignment-race.e2e-spec.ts` › `never answers 304 to the tag of the state before the reassignment once the outlet moved`; `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `builds the unit, the tag and the lists only from what the statement returned when the outlet moved after the outlet was read` |
| A reader anchored only at the unit that used to operate the outlet, after the outlet moved to another unit | the plain `403` on the read and on the search; never the items of the new unit | `test/e2e/catalog-till-reassignment-permission.e2e-spec.ts` › `refuses the read and the search once the outlet moved before the permission was decided` |
| The outlet moves to another business unit between the permission and the read, for a reader anchored only at the old unit (race) | the permission and the statements that read the unit share one snapshot, so the answer never holds the exclusive items of the new unit, on the read, the search and the barcode scan | `test/e2e/catalog-till-reassignment-permission.e2e-spec.ts` › `answers the old unit and its item, never the exclusive items of the new unit, when the outlet moves between the permission and the read`; `test/e2e/catalog-till-reassignment-permission.e2e-spec.ts` › `answers the old unit and its item to a search, never the exclusive items of the new unit, when the outlet moves between the permission and the read`; `test/e2e/catalog-till-reassignment-permission.e2e-spec.ts` › `answers no match for the barcode of an exclusive item of the new unit when the outlet moves between the permission and the scan`; `src/foundation/transactions/tenant-transaction-runner.service.spec.ts` › `opens a read-only repeatable-read transaction before applying the context when the work needs one snapshot` |
| A refused member sends the owner's current tag | the plain `403`, never `304`, at another outlet and at an unknown one | `test/e2e/catalog-till-access.e2e-spec.ts` › `answers a refused member who sends the owner's current tag the plain denial, never 304, at its outlet's neighbour and at an unknown outlet` |
| The read issues an exact number of statements | 8 for `200` when no listed item or variant has an image (9 with images, AC4), 7 for `304`, guards, `BEGIN` and `COMMIT` included | `test/e2e/statement-budget.e2e-spec.ts` › `$route issues exactly $statements SQL statements` |
| `If-None-Match` compares weakly | the strong tag, its weak form, a list entry and `*` match | `src/foundation/http/conditional-requests/conditional-request.spec.ts` › `compares weakly, so the strong tag, its weak form, a list entry and "*" all match` |
| A restaurant journey | modifiers and defaults at the waiter's till; a unit manager's delisting removes the dish; a deactivated category leaves the tree while its dish sells | `test/e2e/catalog-till-journey.e2e-spec.ts` › `a restaurant till loads its dishes with their modifiers and defaults, stops showing a dish its unit manager delists, and keeps selling the dishes of a category the owner deactivates under no category` |
| Per-variant offers (K9f): an active variant offer replaces the item offer, an inactive one opts the variant out, a missing or retired one inherits; a required group without available options on one variant | only that variant leaves `variants[]`, the item stays while another variant is configurable; the item-level `modifierGroups` are unchanged | `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `judge every variant and list its offers through the one effective-offer fragment: the till, the search, the scan, resolve and admit` |
| A required item-level group suspended (`inactive`, K9f), then resumed | every variant that inherits it leaves the lists, a variant whose active offer replaces it stays; all come back on resume | `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `hides every variant while a required item-level group is suspended and shows them again when it resumes`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `ignores a retired offer before judging a required group, and keeps a suspended one as required but unavailable` |
| A required group whose options left still reach its minimum in units after another option is deactivated or archived (F1); its only option's maximum lowered below the minimum, then raised again; an extra whose target item is paused | the item stays listed, scanned, found and resolved; omitted from every reader and refused by `admit` with `required_modifier_unsatisfiable`, then sold again; the lowering itself is accepted (option writes do not re-judge offers) | `test/e2e/catalog-unit-capacity.e2e-spec.ts` › `keeps an item sellable while the options left can still reach its minimum in units, hides it when they cannot, and refuses to offer that minimum again`; `test/e2e/catalog-unit-capacity.e2e-spec.ts` › `counts no unit of an extra whose target item is paused, in the offer command as in every reader`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `judges a required group satisfiable by the units its available options can reach, not by how many options it has` |
| A variant with an active override, one with an opt-out, one that inherits (K9g, written by the variant route) | `variants[].modifierGroupOverrides` only on the first two (`optedOut` false with its rule and defaults, true for the drop); a group only an override offers joins the top-level `modifierGroups` | `test/e2e/catalog-variant-offer-till.e2e-spec.ts` › `answers on the till list and the search the override and the opt-out of each variant, and the groups only an override offers`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `answer as the overrides of each listed variant exactly the rows that keep the item offer from it: its live rows, an inactive one as an opt-out` |
| `admit` on each of those variants (K9g) | exactly the rule the till shows: the item maximum on the inheriting one, the override's own maximum and required group on the overriding one, `option_not_offered` on the opted-out one | `test/e2e/catalog-variant-offer-till.e2e-spec.ts` › `admits on each variant exactly the rule the till shows for it` |
| An item with no override; a variant offer changed (K9g) | no `modifierGroupOverrides` key at all (zero bytes added); the tag moves and the old `If-None-Match` answers `200` | `test/e2e/catalog-variant-offer-till.e2e-spec.ts` › `adds no byte to an item without overrides and moves the till tag when a variant offer changes` |
| A suspended item offer; a variant row retired by the variant route; an override of a deactivated group (K9g) | the item `modifierGroups` leave the suspended offer out while the override stays; the retired row and the override with nothing to sell are not answered, a variant left with none carries no key; the overrides come from the same live-row fragment `effectiveOffer` uses | `test/e2e/catalog-variant-offer-till.e2e-spec.ts` › `never answers a suspended item offer, a retired variant row or an override of a group with nothing to sell`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `matches every row of this variant that is not retired, active or inactive` |
| The menus an outlet shows on its till (M2b): two active menus assigned in order | `menus` in the outlet order, each `{ menuId, name, sections }`, a section `{ sectionId, name, entries, subsections }`, a subsection without `subsections`, an entry `{ itemId }` or `{ itemId, variantIds }` for selected variants; the same answer for the owner and a member holding only `catalog.sellables.view` | `test/e2e/catalog-till-menus.e2e-spec.ts` › `answers the two active menus the outlet shows, in its order, the same to the owner and to a member who only sees what the outlet sells` |
| What a menu leaves out of the till (M2b): a draft and an archived menu; a suspended or retired section with its subsections; a suspended entry; an entry whose item is delisted at the unit or inactive; a selected variant that is inactive or not configurable | none of them; a section left without entries and without non-empty subsections, and a menu left without sections, are dropped | `src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts` › `answers the active menus in the outlet order with their active sections, subsections and the entries the till sells, dropping what is left empty` |
| The SQL against the menu availability rule of the domain (M2b) | the statement answers exactly what `isAvailableEntry` and `isActivePart` decide on the stored menus and the items of the same answer | `src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts` › `agrees with the menu availability rule of the domain applied to the stored menus and the items of the same answer` |
| A truncated list with menus (M2b) | an entry keeps an item that sells beyond the 2,000 answered; the till resolves it through C19 | `src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts` › `keeps an entry whose item sells beyond a truncated list, so the till resolves it through the search` |
| An outlet nobody assigned menus to (M2b) | `menus: []` and every other key byte for byte as before M2b | `test/e2e/catalog-till-snapshot.e2e-spec.ts` › `answers a till with a classified item offering a required group, a plain item, an item with two variants and a dessert exactly as the golden file holds it`; `src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts` › `reads only the menus of the outlet asked for: a sibling outlet of the same organization keeps its own and one without an assignment reads none` |
| A sibling outlet of the same organization with its own menus; a third outlet without an assignment (M2b) | each outlet reads only its own menus; the third reads `menus: []` while it still sells | `src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts` › `reads only the menus of the outlet asked for: a sibling outlet of the same organization keeps its own and one without an assignment reads none` |
| Stored positions that contradict names and creation order (M2b) | menus in the outlet positions, sections, subsections and entries in their stored order, never by name or id | `src/modules/catalog/infrastructure/till/till-menus.repository.integration-spec.ts` › `follows the stored positions of the menus, sections, subsections and entries, not their names or creation order` |
| A menu assigned, activated, reordered, a section renamed, a section suspended (M2b) | each moves the till tag; the `If-None-Match` read before answers `200` with the new tag and the new menus | `test/e2e/catalog-till-menus.e2e-spec.ts` › `moves the till tag when a menu is assigned, activated, reordered, a section renamed or suspended, so a tag read before any of them no longer answers 304` |
| Statements with menus (M2b) | the same as without menus: 8 for `200`, 7 for `304` | `test/e2e/catalog-till-menus-statement-budget.e2e-spec.ts` › `reads what an outlet with two menus sells in the same statements as one without menus, and answers 304 in the same` |
| Plan of the menus on stale statistics (M2b): 2,001 items, 10 menus of 1,000 entries, 3 active | no sequential scan of a menu tree table, no looped scan wider than 100 rows, no lookup wider than one section's 200 entries, at most 14 rows and 40 buffers per shown entry | `src/modules/catalog/infrastructure/till/till-menus.plan.integration-spec.ts` › `reads the menu tables with statistics that miss the import`; `src/modules/catalog/infrastructure/till/till-menus.plan.integration-spec.ts` › `composes the menus of the outlet reading each section and entry by its key, never a whole table, and a bounded cost per entry it shows` |
| Wire shape of the menus (M2b) | ids only; `variantIds` only on a selected entry; a nested subsection or an item field refused by the schema; the example accepted | `src/modules/catalog/presentation/dtos/till/till-menus-response.spec.ts` › `answers the menu tree by ids, with variantIds only on an entry that offers selected variants`; `src/modules/catalog/presentation/dtos/till/till-menus-response.spec.ts` › `refuses a subsection inside a subsection and an entry carrying anything about the item`; `src/modules/catalog/presentation/dtos/till/till-menus-response.spec.ts` › `documents an example the schema accepts` |
| A plain item next to a combo (B2c) | the same bytes as before the composition, without a `bundleGroups` key | `test/e2e/catalog-till-combo-composition.e2e-spec.ts` › `answers a plain item byte for byte as before, without a bundleGroups key, next to a combo` |
| A cashier, refused the back-office composition, reads a combo with a suspended group, a suspended choice, an inactive and an unlisted component, a variant-specific choice and an optional group left with nothing (B2c) | `bundleGroups` with the active groups and only the choices a sale accepts, in order, with the B1 field names; no key on the plain components | `test/e2e/catalog-till-combo-composition.e2e-spec.ts` › `shows a cashier, who cannot read the back-office composition, the groups and choices a sale of the combo accepts, and no key on a plain item` |
| A choice of a combo suspended (B2c) | the tag moves, the old `If-None-Match` answers `200` and the group leaves `bundleGroups` | `test/e2e/catalog-till-combo-composition.e2e-spec.ts` › `moves the tag when the composition changes, so a till holding the old one reads the new choices` |
| Every live choice of a combo admitted alone by `admit` (B2c): suspended, inactive, unlisted, a required modifier suspended, an incomplete component combo, a component that needs a modifier, a variant-specific choice | the till shows exactly the choices `admit` accepts and hides every one it refuses | `test/e2e/catalog-till-combo-composition-parity.e2e-spec.ts` › `shows every choice a sale of the combo accepts and hides every one it refuses: suspended, inactive, unlisted, unfillable, an incomplete combo and a suspended or empty group` |
| Combos nested three levels deep, and a component combo that would need a fourth (B2c, F1b) | the same parity at every level; the deeper component hidden | `test/e2e/catalog-till-combo-composition-parity.e2e-spec.ts` › `judges a nested combo three levels deep as the sale does, hiding a component combo that would need a fourth level` |
| A selection that follows the components by reference into a fourth combo level (B2c) | the component carries `bundleGroups`; `admit` refuses the request `too_deep` | `test/e2e/catalog-till-combo-composition-parity.e2e-spec.ts` › `lets the till see, by the bundleGroups key of a component, that a combo chosen at the third level cannot open a fourth: the sale refuses it as too deep` |
| The till and the sellable rule judge a choice with one text (B2c) | `choiceAvailable` is what `bundleComposable` counts at each level and what both till statements carry | `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `is the very text bundleComposable counts a choice with at each combo level, so one rule decides which choices count and which the till shows`; `src/modules/catalog/infrastructure/till/till-bundle-groups.queries.spec.ts` › `is evaluated once per item and is all both till statements carry about a combo: its composability and its groups, with no second copy of the choice rule` |
| A page of 50 combos holding combos three levels deep in the same page, under stale statistics (B2c) | every group with a choice and a three-level chain of combos in the page; no sequential scan, no looped scan wider than 100 rows, at most 350 rows and 1,100 buffers per requested item | `src/modules/catalog/infrastructure/till/till-read.plan.integration-spec.ts` › `searches a page of fifty combos, each with its groups and the choices the sale accepts nested three levels deep, reading every group and choice by its key` |
| An empty category, and one whose only items are hidden at the outlet, delisted from the unit, archived or inactive (OV25) | the category is not in `categories` | `test/e2e/catalog-till-category-pruning.e2e-spec.ts` › `sends no category that holds nothing the outlet sells: an empty one, and one whose only items are hidden at the outlet, delisted from the unit, archived or inactive` |
| A category with no item of its own and a sellable item in a descendant (OV25) | it is sent with the ancestors that lead to it; the siblings that hold nothing are not | `test/e2e/catalog-till-category-pruning.e2e-spec.ts` › `sends a category with no item of its own when a descendant holds a sellable item, with the ancestors that lead to it and without the siblings that hold nothing` |
| An item linked to a category as secondary only (OV25) | the category is not sent and the item keeps its primary category | `test/e2e/catalog-till-category-pruning.e2e-spec.ts` › `counts the primary category only: a category reached by a secondary link alone is not sent and the item keeps its primary category` |
| Two outlets of one unit, one hiding the only item of a category (OV25) | each outlet is judged on its own: the other still receives the category | `test/e2e/catalog-till-category-pruning.e2e-spec.ts` › `prunes per outlet: the sibling outlet of the same unit keeps the category the first outlet hides` |
| An item whose primary category is inactive (OV25) | it keeps selling with `categoryId: null` and the inactive category is not sent | `test/e2e/catalog-till-category-pruning.e2e-spec.ts` › `keeps selling an item whose primary category is inactive with no category and does not send that category` |
| The last sellable item of a category hidden after a read (OV25) | the tag moves and the earlier `If-None-Match` answers `200`, never `304` | `test/e2e/catalog-till-category-pruning.e2e-spec.ts` › `moves the tag when the last sellable item of a category is hidden, and the tag it had no longer answers 304` |
| The search of what an outlet sells (OV25) | carries no category tree and is not pruned; each item still names its primary category | `test/e2e/catalog-till-category-pruning.e2e-spec.ts` › `leaves the search without a category tree, each item naming its primary category` |
| The tree of a limited read (OV25) | the categories cover the whole outlet whatever the page of items | `src/modules/catalog/infrastructure/till/till-read.categories.repository.integration-spec.ts` › `sends the categories that hold something the outlet sells across the whole outlet, whatever the page of items, and leaves out the empty ones and the folders with nothing below` |
| A text option and an instruction option with a prefix in an offered group (K14d) | each option answers `kind`, and a text option `textMaxLength`, `prefix` and `printsOnReceipt: false` only when they differ from the default; an option with defaults carries none of those keys | `test/e2e/catalog-text-selections.e2e-spec.ts` › `shows a text option on the till with its kind, length, prefix and a print hint switched off, and an option with defaults with none of those keys`; `src/modules/catalog/infrastructure/till/published-groups.spec.ts` › `flattens the presentation an option holds and leaves an option with defaults exactly as it was`; `src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts` › `answers the presentation of an option only when it differs from the default, and parses it as the wire contract` |

### C19 — Search what an outlet sells

`GET /catalog/outlets/{outletId}/sellable-item-search?q=&limit=&after=&before=`,
the access rule of C17; the item shape of C17; keyset cursor bound to the
route, the organization, the outlet, the unit operating it today and `q`;
`meta.pageInfo` and `meta.catalogVersion`; no ETag.

| Case | Expected answer | Proving test |
|---|---|---|
| Prefix of name, code or SKU, any case or accent; `%`, `_` and a backslash; a delisted or inactive item | found; literal; an empty page exactly like an unknown name | `test/e2e/catalog-till-search.e2e-spec.ts` › `finds by a prefix of the name, the code or a SKU ignoring case and accents, takes %, _ and a backslash literally, and never finds what the outlet does not sell, exactly like what does not exist` |
| The SKU of an active, an inactive and an archived variant | the till finds the item only by the active one; the back-office list by all three | `test/e2e/catalog-till-search.e2e-spec.ts` › `finds an item by the SKU of an active variant only, never by the SKU of an inactive or archived one, while the back office still finds all three` |
| A tampered cursor; one of another outlet, `q` or unit; both cursors, `limit=101`, empty or long `q`, `barcode` | `400 · collection.cursor_tampered`; `400 · collection.cursor_incompatible`; `400 · foundation.validation_failed` | `test/e2e/catalog-till-search.e2e-spec.ts` › `refuses a tampered cursor, a cursor of another outlet, another q or another business unit, both cursors and a limit above 100 with 400` |
| The outlet moves to another business unit while a page is read | the page is the page of the unit the outlet had when the read started, with a cursor that unit accepts, never a mix; the cursor is refused `400 · collection.cursor_incompatible` on a later request, once the unit changed | `test/e2e/catalog-till-search-reassignment-race.e2e-spec.ts` › `answers the next page of the business unit the outlet had when the page was read, with a cursor that unit accepts, when the outlet moves meanwhile, and refuses that cursor afterwards`; `test/e2e/catalog-till-search-reassignment-race.e2e-spec.ts` › `answers the first page of the business unit that operated the outlet when the page was read, never a mix with the new unit, when the outlet moves meanwhile`; `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `refuses a cursor issued for the unit the outlet had when the statement read another one, and answers the unit of the statement otherwise` |
| Items added while paging; paging back | no gap, no duplicate; `meta.catalogVersion` constant on a stable catalog and newer after a write | `test/e2e/catalog-till-search.e2e-spec.ts` › `pages forward and back with no gap and no duplicate while items are added, and the pages of a stable catalog carry one catalog version that moves when the catalog changes` |
| A unit that sells nothing | empty pages, with and without `q` | `test/e2e/catalog-till-search.e2e-spec.ts` › `answers an empty page at a unit that sells nothing, before and after a listing mode change` |
| The search issues an exact number of statements | 8 when no item of the page has an image (9 with images, AC4), guards, `BEGIN` and `COMMIT` included | `test/e2e/statement-budget.e2e-spec.ts` › `$route issues exactly $statements SQL statements` |
| A retail journey | the scan resolves the exact variant and a few letters find the same shirt | `test/e2e/catalog-till-journey.e2e-spec.ts` › `a retail till scans a barcode to the exact variant and finds the same shirt by typing a few letters` |
| Per-variant offers (K9f) | the search leaves out the same unconfigurable variants as the till, and the item while no variant is configurable | `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override` |
| The overrides and opt-outs of each variant (K9g) | the same `modifierGroupOverrides` as C17 | `test/e2e/catalog-variant-offer-till.e2e-spec.ts` › `answers on the till list and the search the override and the opt-out of each variant, and the groups only an override offers` |
| A combo found by the search (B2c) | the same `bundleGroups` as the till read | `test/e2e/catalog-till-combo-composition.e2e-spec.ts` › `answers the search with the same composition the till read carries` |
| The search of an item whose offers limit options (K14b) | the same `optionLimits` as the till read, in the same statement text | `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `carries the limits in the offers of the item and of each variant override of both till statements` |

## Assortment

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

`PUT /catalog/business-units/{businessUnitId}/assortment` with `{ mode }`
and `GET /catalog/business-units/{businessUnitId}/assortment`; needs
`catalog.assortment.manage` at the unit (organization-wide or assigned to
it), the read also `catalog.items.view`; the mode change is gated by
`catalog.items`.

| Case | Expected answer | Proving test |
|---|---|---|
| A unit nobody configured (since D1, the active `all` row provisioning stored for the first unit) | `200`, `mode: all`, `listedCount` the active items, a strong ETag, `private, no-cache`; `304` for the tag in its strong and weak form; nothing written | `test/e2e/catalog-assortment.e2e-spec.ts` › `answers a unit nobody configured as selling every active item, with a strong ETag, private, no-cache, and 304 for its tag in the strong and the weak form, writing nothing` |
| The first unit of an organization, or a unit registered with verticals that already run in another unit (D1) | the row stored when the registration commits reads `all`; its tills sell every active item | `test/e2e/business-unit-default-assortment.e2e-spec.ts` › `sells the whole catalog at the first unit of the organization`; `test/e2e/business-unit-default-assortment.e2e-spec.ts` › `sells the whole catalog at a unit registered with a vertical another unit runs, at its tills too` |
| A unit registered with a vertical no other unit runs, or with none (D1) | `none`; its tills sell nothing until a curator chooses the mode, and then follow it | `test/e2e/business-unit-default-assortment.e2e-spec.ts` › `sells nothing at a unit registered with a vertical no other unit runs until a curator chooses its assortment, and its till follows` |
| A vertical turned on after the registration committed (D1) | the stored default stays; nothing recomputes it | `test/e2e/business-unit-default-assortment.e2e-spec.ts` › `sells nothing at a unit registered without verticals, and a vertical turned on later leaves the stored assortment` |
| A mode chosen right after registering (D1) | `all`, `selected` or `none` replaces the default | `test/e2e/business-unit-default-assortment.e2e-spec.ts` › `keeps the %s mode a curator chooses right after registering, whatever the default was` |
| Another organization running the vertical (D1) | never counts: the unit starts at `none` | `test/e2e/business-unit-default-assortment.e2e-spec.ts` › `never counts the units of another organization as running the vertical` |
| Switch to `selected` with nothing listed; the same mode again | `200`, `listedCount: 0`, a new tag, `private, no-store`, one `catalog.assortment.mode_changed` with the mode before and after; the repeat answers the same tag, no record, no `Idempotent-Replayed` | `test/e2e/catalog-assortment.e2e-spec.ts` › `switches a unit to selected with nothing listed, answering listedCount 0 with a new tag and private, no-store, records the mode before and after once, and answers the same mode again with the same tag and no record`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `answer the same mode again with nothing written or recorded` |
| A stale or weak `If-Match` | `412 · foundation.precondition_failed`, nothing written; the current tag is accepted | `test/e2e/catalog-assortment.e2e-spec.ts` › `refuses a stale or weak If-Match with 412 and writes nothing, and accepts the current tag`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `refuse a stale If-Match after the version lock and the stored mode, writing nothing, and accept the tag naming the version, the mode and the unit status` |
| The tag the unit had before a mode switch; another organization writing its own catalog | `200` with the new mode and a new tag; `304` while only the other organization writes | `test/e2e/catalog-assortment.e2e-spec.ts` › `answers 200 for the tag a unit had before its mode switched, and keeps answering 304 while another organization writes its own catalog` |
| The unit is deactivated, then reactivated | `200` with `listedCount` `0`, then back, and a different tag each time, never `304` for the tag it had (the tag names the mode and the unit status) | `test/e2e/catalog-assortment.e2e-spec.ts` › `answers a new tag and the new count once the unit is deactivated or reactivated, never 304 for the tag it had` |
| How each mode is stored and read | no row and an active `all` row read `all`, an active `selected` row `selected`, an inactive row `none`; `none` keeps the mode it had | `src/modules/catalog/domain/assortment/assortment.spec.ts` › `reads %s as %s`; `src/modules/catalog/domain/assortment/assortment.spec.ts` › `stores all and selected as an active row of that mode`; `src/modules/catalog/domain/assortment/assortment.spec.ts` › `stores none as an inactive row that keeps the mode it had, all without a row`; `src/modules/catalog/infrastructure/assortment/assortment.repository.integration-spec.ts` › `stores the mode of a unit once and later changes only its mode and status` |
| A lapsed plan | the mode change `403 · platform.capability_not_entitled`; reading and delisting still `200` | `test/e2e/catalog-assortment-access.e2e-spec.ts` › `keeps reading and delisting open on a lapsed plan, refuses setting the mode and listing with platform.capability_not_entitled, and never tells a manager of another unit about the plan` |
| An inactive unit | the mode change and a listing `422 · authorization.place_not_operational`; an organization-wide holder still reads and delists; a manager assigned to it `403 · authorization.scope_unavailable` | `test/e2e/catalog-assortment-access.e2e-spec.ts` › `refuses to set the mode or list at an inactive unit with authorization.place_not_operational, still lets an organization-wide holder read and delist there, and answers a manager anchored at it that its place is unavailable` |

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

`POST /catalog/business-units/{businessUnitId}/assortment/listing-changes`
with `{ list, delist }` (up to 200 each, at least one item, never one in
both); gated by `catalog.items` only when `list` names an item (API v7 PR 7;
until then `.../listings` and `.../delistings` with `{ itemIds }`).

| Case | Expected answer | Proving test |
|---|---|---|
| List and delist in `selected` mode | `200` with `listedCount`; one record per call naming the items that changed; a repeat records nothing | `test/e2e/catalog-assortment.e2e-spec.ts` › `lists and delists in selected mode, records once per call the items that changed, and records nothing for a repeat`; `src/modules/catalog/domain/assortment/assortment.spec.ts` › `lists in selected mode by inserting a missing inclusion and reactivating an inactive row`; `src/modules/catalog/domain/assortment/assortment.spec.ts` › `delists in selected mode only by deactivating an inclusion, since an item without a row is not listed` |
| Delist in `all` mode and list back | an exclusion row, then lifted; the item status and every other unit untouched; an archived item may be delisted | `test/e2e/catalog-assortment.e2e-spec.ts` › `delists in all mode by excluding the item and lists it back, never changing the item status nor what another unit sells, archived items included`; `src/modules/catalog/domain/assortment/assortment.spec.ts` › `delists in all mode by inserting a missing exclusion and deactivating an active row`; `src/modules/catalog/domain/assortment/assortment.spec.ts` › `lists in all mode only by lifting an exclusion, since an item without a row is already listed` |
| Only what changes is written | missing rows inserted, others change only their status; nothing when every item is already in the requested state | `src/modules/catalog/domain/assortment/assortment.spec.ts` › `writes nothing when every item is already in the requested state`; `src/modules/catalog/infrastructure/assortment/assortment.repository.integration-spec.ts` › `inserts the missing listings and only changes the status of the others, while the assortment guard keeps the business unit row locked until commit`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `answer a curation that changes nothing with nothing written or recorded` |
| 200 items in one call | `200`, one record naming all of them | `test/e2e/catalog-assortment.e2e-spec.ts` › `lists 200 items in one call, one record naming them all` |
| No item, 201 items in a list, a repeated item, an item in both lists, a malformed id, the former `itemIds`, an unknown mode | `400 · foundation.validation_failed` pointing at the field, such as `delist[0]` | `test/e2e/catalog-assortment.e2e-spec.ts` › `refuses a change naming no item, more than 200 items in a list, a repeated item, an item in both lists, a malformed id and an unknown mode with 400, pointing at the field`; `src/modules/catalog/presentation/dtos/assortment/assortment.schemas.spec.ts` › `points at the delisting of an item the same change lists` |
| List and delist in one change; the former routes | `200`; one `items_listed` and one `items_delisted` record, each only when its direction changed; `404` for `.../listings` and `.../delistings` | `test/e2e/catalog-assortment.e2e-spec.ts` › `lists and delists in one change, keeps one record per direction that changed, and no longer serves the former listings and delistings routes`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `list and delist in one change: ask about the plan once, share every item in one read, write each direction once and keep one record per direction that changed`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `record only the direction that changed when the other one finds every item already in place` |
| A change with an archived item to list or an unknown item to delist; a refused change | the whole change refused, nothing written; the refusal recorded under `items_listed` when `list` names an item, else `items_delisted` | `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `refuse a whole change when an item to list is archived or an item to delist is unknown, writing nothing`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `record a refused change that lists under the listing action and a delisting-only one under the delisting action` |
| An archived item listed; any curation while the mode is `none` | `422 · catalog.item_archived`; `422 · catalog.assortment_disabled`; nothing written; the listings come back with the mode | `test/e2e/catalog-assortment.e2e-spec.ts` › `refuses listing an archived item with catalog.item_archived and any curation while the unit sells nothing with catalog.assortment_disabled, writing nothing, and keeps the listings when the unit is switched back on`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `refuse listing an archived item, an unknown item and any curation while the unit sells nothing, writing nothing` |
| A forged `organizationId` or `businessUnitId` in the body; an item of another organization | `400`; the same `404 · catalog.not_found` as an unknown item, the whole call refused | `test/e2e/catalog-assortment-access.e2e-spec.ts` › `takes the unit only from the path: refuses a forged organizationId or businessUnitId in a body, and answers an item of another organization exactly like an unknown item, writing nothing`; `src/modules/catalog/infrastructure/assortment/assortment.repository.integration-spec.ts` › `shares no item of another organization and no unknown item` |
| The till follows the rows: `selected` without the listing, `none`, `all`, a delisting at one unit | `data: null` at that unit's outlet while another unit's outlet resolves; nothing in `none`; everything in `all`; another unit unaffected | `test/e2e/catalog-assortment-till.e2e-spec.ts` › `answers null at the till of a unit in selected mode that has not listed the variant, while the till of a unit that sells everything resolves it, and resolves it there once listed`; `test/e2e/catalog-assortment-till.e2e-spec.ts` › `answers null at every till of a unit that sells nothing, resolves at a unit in all mode, and a delisting at one unit never changes what the till of another unit finds` |

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

`GET /catalog/items?businessUnitId=&listed=true|false` (C2 with the
assortment filter) and the count of `GET .../assortment` (C14); the
workspace answers the mode, `curate` and the OD5 warning.

| Case | Expected answer | Proving test |
|---|---|---|
| `listed=true` and `listed=false` in `all`, `selected` and `none` mode, paged | the items the unit sells and the others; the cursor binds `listed` (`400 · collection.cursor_incompatible` across) | `test/e2e/catalog-assortment-reads.e2e-spec.ts` › `lists the items a unit sells with listed=true and the others with listed=false, following its mode, and pages them with a cursor bound to that filter` |
| `listed` agrees with `catalog.listed_items` and `catalog.variant_is_listed` | the same items for no row, `all`, `selected` and turned off, at an active and an inactive unit, with a listing, an exclusion and no row; `listed=false` is the rest | `src/modules/catalog/infrastructure/reads/listed-filter.integration-spec.ts` › `agrees with catalog.listed_items and catalog.variant_is_listed for every mode, unit status and listing state` |
| `listed` in OpenAPI and in the request | the closed choice `true` or `false`; anything else, or `listed` without a unit, `400` | `src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts` › `documents listed as the closed choice of true or false`; `src/modules/catalog/presentation/dtos/reads/list-items-query.schema.spec.ts` › `accepts only true or false for listed, and only with a business unit` |
| `listed` without `businessUnitId`; a manager of one unit | `400`; `200` at its unit, `403` at another | `test/e2e/catalog-assortment-reads.e2e-spec.ts` › `refuses listed without a business unit, and lets a manager of one unit filter only at that unit` |
| `listedCount` | exactly what `catalog.listed_items` admits: every active item without a row, only listings in `selected`, nothing when off | `src/modules/catalog/infrastructure/assortment/assortment.repository.integration-spec.ts` › `counts what a unit sells exactly as catalog.listed_items: every active item without a row, only the listings in selected mode, nothing when turned off` |
| OD5 warning only to an organization-wide curator | a manager of one unit and a reader of the whole catalog get `[]`; an organization-wide holder of `catalog.assortment.manage` gets `units_with_different_verticals` | `test/e2e/catalog-assortment-reads.e2e-spec.ts` › `tells only an organization-wide curator that units run different verticals, never a reader assigned to one unit nor a reader of the whole catalog` |
| The workspace offers `curate` | to the owner and to a manager of that unit on the current plan; not to a mere viewer nor on a lapsed plan; nothing recorded | `test/e2e/catalog-assortment-reads.e2e-spec.ts` › `offers curating the named unit only to a caller who may curate it on the current plan, and records nothing` |
| OD5: active units with different verticals | `warnings: ["units_with_different_verticals"]`; none for the same verticals or once the other unit is inactive | `test/e2e/catalog-assortment-reads.e2e-spec.ts` › `warns in the workspace when the active business units of the organization run different verticals, and not when they run the same ones`; `src/modules/catalog/application/reads/workspace-verticals.spec.ts` › `asks Organization for the active units, then Platform for the verticals of exactly those units`; `test/e2e/catalog-workspace-parity.e2e-spec.ts` › `answers the same catalog, counts and warnings the single statement read before K10b, through every mix of units and verticals, and the same body on a second read a second later, its page cursors bound to the same keyset position and still accepted by GET /catalog/items` |

## Internal contract

### C20 — Resolve and admit a sellable variant

`CatalogSellableContract` (no HTTP route): `resolve(context, { businessUnitId,
variantIds })` and `admit(context, { businessUnitId, lines })` in the
caller's tenant transaction, proven through a consumer module that sees only
the contract (`test/fixtures/catalog-sellable/`).

| Case | Expected answer | Proving test |
|---|---|---|
| A sellable variant at its unit | `sellable: true`, `refusal: null`, the snapshot (item and variant names and codes, SKU, sales unit and quantity policy, the effective offers with their id, source, available options, each target's sales unit and defaults) and the catalog version C17 answers | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `resolves a sellable variant into its snapshot, with its effective offers, their available options and defaults, and the catalog version it was read at`; `src/modules/catalog/application/sellable/catalog-sellables.spec.ts` › `answers a verdict per requested variant in the requested order, with the catalog version, in one read and without any lock` |
| A variant of another organization, an unknown or a malformed one; a unit of another organization, an unknown, an inactive or a malformed unit | `unknown` with no snapshot, identical for all three; `business_unit_unavailable` for all four units, the variant's own snapshot kept | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `answers another organization variant exactly like an unknown one, and another organization unit, an unknown, an inactive and a malformed unit as the same unavailable unit`; `src/modules/catalog/application/sellable/catalog-sellables.spec.ts` › `answers a malformed variant as unknown and a malformed unit as no unit, without sending either to SQL` |
| An archived or inactive item, an archived or inactive variant, a unit that sells nothing, a delisted item, an item not selected, an unsatisfiable required group | `item_archived`, `item_inactive`, `variant_archived`, `variant_inactive`, `assortment_none`, `not_listed`, `not_listed`, `required_modifier_unsatisfiable`, the first failing fact in that order | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `names why a variant is not sellable: archived or inactive item, archived or inactive variant, assortment none, not listed and an unsatisfiable required group`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `answers no refusal when the shared rule says sellable, and unknown when the variant was not found`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `names the first failing fact in a fixed order: unit, item, variant, assortment, listing, required groups, combo groups`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `answers not listed when the shared rule refuses for a reason no diagnostic column names` |
| The verdict is the shared rule of the till lists and the scan | the same fragment inside the statement; the scan, the sellables read and the contract agree on every case of the rule, the required group lost to an inactive option, group or target variant included | `src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts` › `judge sellability with the one shared rule of the till lists and the scan, for the requested variants at the requested unit`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `trusts the shared rule over every diagnostic column, so a sellable verdict is never refused`; `test/e2e/catalog-till-parity.e2e-spec.ts` › `gives the scan, the sellables read and the sellable contract the same verdict for a listed and a delisted item, an inactive variant, a satisfiable and an unsatisfiable required group, and an inactive group, option, target variant or target item` |
| An admission with chosen options, the preselected default among them, and the same variant on two lines | every line admitted with its `lineIndex`, the snapshot and catalog version `resolve` answers, its quantity in the shortest decimal form and its effective offers holding its selections | `test/e2e/catalog-sellable-offers.e2e-spec.ts` › `admits quantities and answers the effective offer identity and everything a sale line stores`; `src/modules/catalog/application/sellable/catalog-sellables.spec.ts` › `shares the locks before it reads, and answers every line with its index, its snapshot, its quantity and the effective offers holding its selections` |
| An option of another item, an inactive option of an offered group, fewer or more units than an offer allows, an archived item, a line quantity off policy, an option above its maximum | `CatalogNotSellableError` with every refusal per line: `option_not_offered`, `option_unavailable`, `selection_below_minimum`, `selection_above_maximum`, `item_archived`, `quantity_out_of_policy`, `option_quantity_above_maximum`; nothing admitted | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `refuses the whole admission, line by line, for an option of another item offer, an unavailable option, a count below the minimum or above the maximum, and an archived item`; `src/modules/catalog/application/sellable/catalog-sellables.spec.ts` › `refuses the whole admission with every refusal: a variant that is not sellable, an unknown one, an option not offered or unavailable, a broken selection count, a line quantity off policy and an option quantity above its maximum`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `accepts a selection inside every offered rule`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `refuses an option no offer of the item holds, and one an offer holds but that is not available`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `refuses a group chosen below its minimum or above its maximum, in offer order` |
| No variants or lines, more than 100, a repeated variant, a repeated option on a line, more than 100 options on a line | `CatalogSellableRequestInvalidError` (`empty`, `too_many`, `duplicate`, `too_many_options`) before any SQL or lock | `src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts` › `rejects an empty request, more variants than the limit and a repeated variant before any SQL`; `src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts` › `rejects no lines, too many lines, too many options on a line and a repeated option before any lock` |
| A group judged on its units (K9d): three of one sauce in a group of maximum 3, then four; two cheeses and a bacon in a group of maximum 2 | admitted; `selection_above_maximum`; `selection_above_maximum`, as the Sales schema sums `quantity` | `src/modules/catalog/domain/sellable/sellability.spec.ts` › `judges every group on the sum of the chosen quantities, as the sales schema does`; `test/e2e/catalog-sellable-quantities.e2e-spec.ts` › `admits an option chosen several times, judging the option on its maximum, each group on the sum of its units and the line on the sales policy of the variant` |
| Cheese × 2 in a group of minimum 2 (K9d) | admitted with one selection `{ optionId, quantity: 2 }`; one cheese `selection_below_minimum` | `src/modules/catalog/domain/sellable/sellability.spec.ts` › `meets a minimum of two with one option chosen twice` |
| An option chosen more times than its `maxQuantity` (K9d) | `option_quantity_above_maximum` naming the option, then the group verdicts on the units | `src/modules/catalog/domain/sellable/sellability.spec.ts` › `refuses a quantity above the option maximum, naming the option` |
| A line quantity below the variant minimum, above its maximum or off its step counted from the minimum (K9d) | `quantity_out_of_policy`, the rule of the Sales order-line trigger (`mod(quantity - minimum, step) = 0`) | `src/modules/catalog/domain/sellable/sellability.spec.ts` › `refuses a line quantity below the minimum, above the maximum or off the step of the variant` |
| A selection quantity of 0, 1000, 1.5, negative or not a number; a line quantity that is zero, negative, not a decimal or with more than 6 decimals; an option twice in a line in any case (K9d) | `CatalogSellableRequestInvalidError` with `invalid_quantity` or `duplicate` before any lock or SQL | `src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts` › `rejects a selection quantity outside 1 to 999 or not an integer, and an option repeated in a line, before any lock` |
| Parity with the Sales schema (K9d) | for ten selections, the rows Sales would store pass the real `sales.validate_line_modifier_insert` trigger and `sales.assert_order_line_modifier_consistency` exactly when `admit` admits them | `test/e2e/catalog-sellable-quantities.e2e-spec.ts` › `agrees with the sales schema on every selection: the rows built from an admitted line pass the real modifier trigger and group check, and the rows of a refused one fail them` |
| The answer carries the effective offer (K9e) | per line, every effective offer of the variant: `itemModifierGroupId` (the offer's stable id), `modifierGroupId`, `source` (`item` until K9f, `variant` since for an active variant override), min, max, the group name; per selection the option id, name, kind, quantity, target variant and its sales unit | `test/e2e/catalog-sellable-offers.e2e-spec.ts` › `admits quantities and answers the effective offer identity and everything a sale line stores`; `src/modules/catalog/domain/sellable/admitted-offer.spec.ts` › `answers every effective offer of the line with its identity, its rules and the selections it holds, in request order`; `src/modules/catalog/contracts/catalog-sellable.contract.spec.ts` › `names every type its results carry, so a consumer never imports catalog/domain` |
| Removed and reduced defaults (K9e) | an available default not selected is removed; one selected with less than its default quantity is reduced; a default no longer available is never named | `src/modules/catalog/domain/sellable/admitted-offer.spec.ts` › `derives the removed and reduced defaults of each offer from the final selection`; `src/modules/catalog/domain/sellable/admitted-offer.spec.ts` › `never names an unavailable default, which the offer no longer lists among its options` |
| A default with a quantity above one: chosen with less, with as much or more, left out; an unavailable one with a quantity (K14a) | `reducedDefaults` names the one chosen with less than its quantity (`defaultQuantity`, `quantity`); one chosen with as much or more is named nowhere; one left out is `removedDefaults` with its quantity as `defaultQuantity`; an unavailable one is never named and no preselection is added | `test/e2e/catalog-offer-default-quantities.e2e-spec.ts` › `measures every default against its own quantity: admit names the one chosen with less, the one dropped and never adds a preselection`; `src/modules/catalog/domain/sellable/admitted-offer.spec.ts` › `measures each default against its own quantity: a lower selection reduces it, an equal or higher one keeps it, a missing one removes it`; `src/modules/catalog/domain/sellable/admitted-offer.spec.ts` › `never adds a preselection the line did not choose and ignores the quantity of an option that is no longer available` |
| `resolve` and `admit` of an offer that limits options (K14b) | each effective offer lists only the options it still allows: an option limited to 0 is absent, the others carry their effective maximum; choosing the excluded option is `option_not_offered`, more than the effective maximum `option_quantity_above_maximum`; a selection within the limit is admitted | `test/e2e/catalog-offer-option-limits.e2e-spec.ts` › `answers resolve and admit per effective offer: an option limited to zero is not offered, the others are measured against their effective maximum`; `src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts` › `answers each effective offer with the options it still allows: an option limited to zero is absent and the others carry their effective maximum` |
| An offer whose every option is limited to 0 (K14b) | the offer is not offered at all in the snapshot of `resolve`, and the other offers are kept as they are | `src/modules/catalog/domain/sellable/admitted-offer.spec.ts` › `drops an offer whose options are all limited to zero, so nothing is offered where nothing can be chosen, and tidies the others` |
| Parity with the Sales schema (K9e) | the rows built from the admitted answer alone (offer id, names, quantities, the unit of an extra) pass the real `sales.validate_line_modifier_insert` trigger and `sales.assert_order_line_modifier_consistency` | `test/e2e/catalog-sellable-offers.e2e-spec.ts` › `builds sale rows from the admitted answer alone, offer identity, names, quantities and the unit of an extra, and the real sales trigger and group check store every one of them` |
| The effective offer of each variant (K9f): wings ×6 inherits the item rule (max 2), ×25 has an active override (max 6), ×3 an inactive one | `resolve` and `admit` answer the item offer with `source: item`, the override's id with `source: variant`, and nothing for ×3; five units admitted on ×25, three refused on ×6 (`selection_above_maximum`), any sauce on ×3 `option_not_offered`; the Sales rows built from the ×25 answer pass the real trigger and group check, and fail with the item offer id | `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `resolves sauces per variant: inherited, overridden and opted out`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `lets an active variant offer replace the item offer, an inactive one opt the variant out, and a missing or retired one inherit the item offer` |
| An opt-out of a required item-level group (K9f, X13 closed by K9g-0) | `admit` admits the variant with no selection and no offer; Sales agrees since migration `0090`: it stores the line with no selection and refuses a selection through the item offer | `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `admits a variant that opted out of a required item-level group with no selection, and the sales schema agrees: it stores the line with no selection and refuses the item offer (X13 parity)`; `database/tests/contracts/sales-variant-offer-contracts.ts` › `NINAKU_SALES_VARIANT_OFFER_OK` |
| Per-variant sellability (K9f) | an unconfigurable variant is refused with `required_modifier_unsatisfiable` while its siblings are admitted, exactly as the scan, the till and the search | `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `gives the scan, the till, the search and the contract the same per-variant verdict for an override, an opt-out and an unsatisfiable required override`; `test/e2e/catalog-variant-modifier-offers.e2e-spec.ts` › `hides every variant while a required item-level group is suspended and shows them again when it resumes` |
| A combo whose required group is suspended, or whose available choices cannot reach its minimum (B2b), and the same combo once the group can be completed | left out of the till list (not counted in `meta.omittedItems`), the search and the scan; `resolve` answers `sellable: false` with `bundle_group_unsatisfiable`; `admit` explains it with the group at its path (`bundle_group_unavailable` for the suspended group, `bundle_group_unsatisfiable` for the empty one) and never admits a line the rule leaves out; sold again everywhere once resumed. The SQL fragment (`bundleComposable`, a keyed `anyRowOf` probe, no `EXISTS`, inside `sellableCandidate`) answers what the domain `isSatisfiable` answers on the same rows; a catalog of 1,000 combos under stale statistics keeps the till plans within their row and buffer bounds | `test/e2e/catalog-combo-till-parity.e2e-spec.ts` › `leaves out a combo whose required group is suspended or has no available choice everywhere, explains why without counting it as omitted, and sells it again once the group can be completed`; `src/modules/catalog/infrastructure/reads/bundle-composable.integration-spec.ts` › `answers in SQL what the domain rule of satisfiable groups answers for the same combos`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `leaves out a combo with a live required group that is suspended or whose available choices cannot reach its minimum, probing the groups of the item by their index`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `is part of every sellable variant, so the scan and the sellable contract leave the same combos out`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `explains a combo the shared rule leaves out because a required group cannot be completed with the reason admission gives that group`; `src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts` › `answers the combo not sellable with the reason admission gives the group`; `src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts` › `explains the refusal at admission with the group, its reason and the path to fix it`; `src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts` › `refuses the line with the catalog reason when the groups it reads raise nothing, never admitting what the catalog leaves out`; `src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts` › `judges a combo inside a combo that cannot be completed through its own groups, at the path of its choice`; `src/modules/catalog/application/sellable/catalog-sellables-incomplete-combo.spec.ts` › `refuses a combo inside a combo with the catalog reason at the path of its choice when its own groups raise nothing`; `src/modules/catalog/infrastructure/till/till-read.plan.integration-spec.ts` › `lists 2,000 of them, half of them combos nested three levels deep, and the groups they offer reading each item by its key, never the whole organization per item` |
| A combo whose required choice is itself a combo that cannot be completed (F1b): the Pack familiar offering a children combo whose toy group is suspended or emptied, the same Pack with a plain drink as another choice of that group, a megapack three levels up, and a component combo the sale would have to open below the third level | the Pack, the children combo and the megapack are left out of the till list (not counted in `meta.omittedItems`), the search and the scan, and `resolve` answers them `sellable: false` with `bundle_group_unsatisfiable`, while `admit` explains the refusal with the group of the children combo; with the drink still available the Pack stays sold; all of them are sold again once the toy group can be completed. A choice counts only when its component combo can itself be composed, recursively down to the third combo level; below it a component combo with a group the sale would open is not available, as `admit` refuses it with `bundle_too_deep`. The SQL fragment unrolls the three levels as keyed `anyRowOf` probes (no `EXISTS`, no recursive CTE) and answers what the domain `isComposable` answers on the same nested rows; 1,000 combos nested three levels deep under stale statistics keep the till plans within their bounds | `test/e2e/catalog-nested-combo-till-parity.e2e-spec.ts` › `leaves the Pack familiar out with its children combo while the toy group is suspended or emptied, and sells both again once it can be completed`; `test/e2e/catalog-nested-combo-till-parity.e2e-spec.ts` › `keeps selling the Pack familiar when the same required group still offers a plain drink while its children combo cannot be completed`; `test/e2e/catalog-nested-combo-till-parity.e2e-spec.ts` › `carries a toy group that cannot be completed up through three combo levels to the megapack, and back once it can`; `src/modules/catalog/infrastructure/reads/bundle-composable.integration-spec.ts` › `answers in SQL what the domain answers for combos whose choices are themselves combos, down to the deepest level a sale may open`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `asks the same of a component that is itself a combo, unrolled once per permitted combo level, and refuses below the deepest level a component the sale would have to open`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `composes a combo whose required choice is a children combo that can be completed, and not once the toy group of the children combo is suspended`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `keeps a combo composable when another available choice of the same required group can fill it`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `adds only the capacity of the choices that can themselves be composed`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `follows the composition through three combo levels`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `refuses at the deepest permitted level a component that is itself a combo the sale would have to open, as admit refuses it for depth` |
| A combo whose required component the unit cannot sell on its own because of its required modifiers (F1c): the breakfast whose only coffee needs a milk group that is suspended, an offer of it suspended, its options all suspended or too few units to reach its minimum; the same coffee with an optional milk group suspended or a variant that opts out of it; the breakfast with a tea as another choice; a pack and a megapack above the breakfast | the breakfast is left out of the till list, the search and the scan, and `resolve` answers it `sellable: false` with `bundle_group_unsatisfiable`, while `admit` refuses the coffee at its path with `required_modifier_unsatisfiable`; `meta.omittedItems` counts the coffee (a plain item the unit cannot configure), never the breakfast; with an optional milk group, an opted-out variant or a tea it stays sold everywhere; sold again once the milk resumes. A choice counts toward its minimum only when its component would be sold on its own at the reader's unit by the shared rule (`variantConfigurable` reused, recursively down to the third level); the SQL fragment and the domain `isComposable`, which judges each component with `blockingRefusalOf` as `admit` does, answer the same on the same rows; 1,000 combos whose every component carries a required modifier group keep the till plans within their bounds under stale statistics | `test/e2e/catalog-component-combo-till-parity.e2e-spec.ts` › `leaves the breakfast out while its only coffee needs a milk that is suspended or the coffee is not listed at the unit, and sells it again at every step back`; `test/e2e/catalog-component-combo-till-parity.e2e-spec.ts` › `keeps selling the breakfast through a tea of the same required group while its coffee cannot be sold at the unit`; `src/modules/catalog/infrastructure/reads/bundle-component-sellable.integration-spec.ts` › `counts a component only when its required modifiers can be satisfied, as admit judges it, at every combo level`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `counts a choice only when its component would itself be sold at the business unit of the reader: listed there, with its required modifiers satisfiable`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `leaves out a breakfast whose only required coffee cannot be configured at the unit, as admit refuses the coffee, and composes it once the coffee can be configured again`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `keeps a breakfast whose coffee cannot be configured when a tea of the same required group can fill it`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `adds only the capacity of the components that the unit could sell on their own`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `judges the components of a component combo by the same rule, down to the deepest level`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `judges a component combo by its own groups, not by the composability the stored facts carry for it`; `src/modules/catalog/infrastructure/till/till-read.plan.integration-spec.ts` › `lists 2,000 of them, half of them combos nested three levels deep, and the groups they offer reading each item by its key, never the whole organization per item` |
| A combo whose required component is not listed at the business unit (F1c): the breakfast whose only coffee is delisted at the unit, or listed only at another unit; the breakfast with a tea as another choice; a pack above the breakfast; the coffee listed again | the breakfast is left out of the till list (not counted in `meta.omittedItems`), the search and the scan, `resolve` answers `bundle_group_unsatisfiable`, and `admit` refuses the coffee at its path with `not_listed`; a listing at another unit does not count; with the tea it stays sold; sold again once the coffee is listed. The listing probe is the `listedAt` of the shared rule at the reader's unit, keyed by the unit | `test/e2e/catalog-component-combo-till-parity.e2e-spec.ts` › `leaves the breakfast out while its only coffee needs a milk that is suspended or the coffee is not listed at the unit, and sells it again at every step back`; `test/e2e/catalog-component-combo-till-parity.e2e-spec.ts` › `keeps selling the breakfast through a tea of the same required group while its coffee cannot be sold at the unit`; `src/modules/catalog/infrastructure/reads/bundle-component-sellable.integration-spec.ts` › `counts a component only when it is listed at the business unit of the reader, keeping a combo whose alternative is listed`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `leaves out a combo whose only component is not listed at the business unit, or whose unit cannot operate`; `src/modules/catalog/domain/sellable/bundle-composability.spec.ts` › `judges a component it knows nothing about as unavailable, as admit refuses an unknown variant` |
| One option chosen up to twice on a group of minimum and maximum two (F1): twice, once, three times | admitted, and the rows Sales would store pass its real trigger and group check; `selection_below_minimum`; `option_quantity_above_maximum` and `selection_above_maximum` | `test/e2e/catalog-unit-capacity.e2e-spec.ts` › `offers one option chosen up to twice with a minimum of two, sells the item everywhere and admits exactly two units` |
| The statements inside the caller transaction | `resolve` 1; `admit` 3: the version row, the unit row, the read | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `resolves in one statement and admits in three inside the caller transaction: the version row, the unit row, then the read` |
| Resolving, admitting and refusing | nothing written, the catalog version unchanged, no audit record | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `writes nothing and records no audit, whether it resolves, admits or refuses`; `src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts` › `read without any lock or write, scoped to the tenant of the transaction` |
| A future Sales module | may import `catalog/contracts/**` only, and the contract surface names every type its results carry (`ModifierOptionKind` included); the sellable rule, the use case and the repository are refused by R1 | `src/modules/catalog/contracts/catalog-sellable.contract.spec.ts` › `names every type its results carry, so a consumer never imports catalog/domain`; `tooling/repository/check-module-boundaries.spec.ts` › `R1: lets a sales use case consume CatalogSellableContract and refuses the catalog sellable rule, use case and repository behind it` |
| A selection of a text option with a decomposed accent (K14d) | admitted; the admitted selection answers `text` in NFC (`José`, not `Jose` plus a combining accent) and the length is counted after normalizing; a selection without text answers no `text` | `test/e2e/catalog-text-selections.e2e-spec.ts` › `admits a decomposed text and answers it composed (NFC), and counts the composed length against the limit`; `test/e2e/catalog-text-selections.e2e-spec.ts` › `counts the characters of a composed text once after normalizing, so a decomposed text of the same length fits`; `src/modules/catalog/application/sellable/sellable-request.spec.ts` › `normalizes the text of a selection to NFC and keeps a selection without text as it was`; `src/modules/catalog/domain/sellable/admitted-offer.spec.ts` › `carries the text of a selection into the admitted selection and nothing for a selection without text` |
| A text option chosen without its value, with a blank value, with a control character or NUL, or longer than `textMaxLength`; a value sent to an option that is not text | `text_required`, `text_invalid`, `text_invalid`, `text_too_long`, `text_not_allowed`, each naming the option; a text option taken twice is `option_quantity_above_maximum` first | `test/e2e/catalog-text-selections.e2e-spec.ts` › `refuses a text selection without its value, a blank one, one with a control character and one longer than the option, naming the option`; `test/e2e/catalog-text-selections.e2e-spec.ts` › `refuses a value sent to an option that is not a text option and a text option taken twice`; `src/modules/catalog/domain/sellable/text-selection.spec.ts` › `refuses NUL and any control character, a line break and a tab included`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `refuses a text option taken twice by its quantity before judging the value` |
| More than ten selections with text in a line, the components of a combo counted in the same line | `CatalogSellableRequestInvalidError` with `too_many_texts` before any lock | `test/e2e/catalog-text-selections.e2e-spec.ts` › `refuses more than ten selections with text in a line before any lock`; `src/modules/catalog/application/sellable/sellable-request.spec.ts` › `counts the texts of the components of a combo in the same line` |
| An item whose only required group holds a text option | listed on the till and sellable; admission without a selection is `selection_below_minimum`, with the option but no value `text_required`, with the value admitted | `test/e2e/catalog-text-selections.e2e-spec.ts` › `sells an item whose only required group holds a text option, and demands its value` |
| The options of the snapshot of `resolve` and the item read of a text option and of an option with a prefix | the same fields under the same rule; an option with defaults carries no presentation key | `test/e2e/catalog-text-selections.e2e-spec.ts` › `shows the same options on the item read and in the snapshot of resolve`; `src/modules/catalog/domain/sellable/admitted-offer.spec.ts` › `publishes the presentation of an option flat, only the fields that differ from the default, and nothing for an option with defaults` |
| The contract surface of text selections | optional `text`, optional presentation fields and four new refusal reasons, with the ten-text limit published | `src/modules/catalog/contracts/catalog-sellable.contract.spec.ts` › `adds the text of a selection, the presentation of an option and the text refusals as optional, additive pieces` |

## Cross-tenant and concurrency invariants

| Invariant | Expected answer | Proving test |
|---|---|---|
| Every catalog command locks the catalog version row of its organization before any item row | the lock order of §5 | `src/modules/catalog/application/items/create-item.use-case.spec.ts` › `locks the catalog version before any write, generates the code from the name, writes the item and its default variant and records the creation`; `src/modules/catalog/application/items/change-item-status.use-case.spec.ts` › `deactivates an active item after locking the version and the item, and records the status before and after` |
| An edit and a lifecycle change of the same item race | both apply one after the other, no deadlock, no `500` | `test/e2e/catalog-items-concurrency.e2e-spec.ts` › `lets an edit and a deactivation of the same item race without a deadlock: both apply, one after the other`; `database/tests/concurrency/catalog-item-concurrency.ts` › `NINAKU_CATALOG_ITEM_CONCURRENCY_OK: an update and a deactivation of one item lock the catalog version row first, so the second waits and both apply without a deadlock` |
| A creation key is unique per organization; the same key in another organization is independent | `catalog_items_creation_idempotency_uq` | `database/tests/contracts/catalog-item-creation-contracts.ts` › `the same creation idempotency key twice in one organization is a unique violation`; `src/modules/catalog/infrastructure/items/item.repository.integration-spec.ts` › `claims a creation key once per organization and finds the item and the hash of its request by it` |
| A caller without the permission never learns about the plan | the same status and body as any plain denial | `test/e2e/catalog-items-access.e2e-spec.ts` › `answers a member without the permission in an organization without the catalog exactly like any plain denial, so it never learns about the plan`; `src/modules/catalog/presentation/failures/to-public-catalog-failure.spec.ts` › `answers any other operation refusal exactly like a plain denial, so nobody learns about the plan` |
| Every category command locks the catalog version row before the category row, and a refused one locks nothing | the lock order of §5 | `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `lock the catalog version row before the category row, then write and record one change`; `src/modules/catalog/application/categories/edit-category.use-case.spec.ts` › `refuse a member without the manage permission with one denied record and no lock` |
| Two parallel moves that would close a cycle together | one `200`, one `422 · catalog.category_cycle`, the tree stays acyclic | `test/e2e/catalog-categories-concurrency.e2e-spec.ts` › `lets only one of two parallel moves that would close a cycle together succeed, and refuses the other with catalog.category_cycle` |
| Parallel creates, and a rename, a move and a deactivation of one category sent together | all applied one after the other, no deadlock, no `500` | `test/e2e/catalog-categories-concurrency.e2e-spec.ts` › `creates eight categories under one parent in parallel with no deadlock, no conflict and no 500`; `test/e2e/catalog-categories-concurrency.e2e-spec.ts` › `applies a rename, a move and a deactivation of one category sent together, one after the other, with no deadlock` |
| Every created item has exactly one active default variant | written with the item in one transaction; the deferred trigger is the backstop | `test/e2e/catalog-items.e2e-spec.ts` › `creates an item with its active default variant and answers the same body and strong ETag the read answers` |
| Every modifier command locks the catalog version row first, then the group or the item `FOR UPDATE`, then shares the variants or the offered groups in id order; a refused command locks nothing, except an offer refused for the plan, decided after the item lock | the lock order of §5 | `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `locks the version then the group, asks about the plan, share-locks the new variants, then writes the profile, the changed and the new options and one record`; `src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts` › `create a group after locking the catalog version, then share-lock its variants in id order before writing the group, its numbered options and one record`; `src/modules/catalog/application/modifiers/modifier-commands.use-case.spec.ts` › `offer groups after the plain permission, the version and the item, asking about the plan before share-locking the groups in id order and replacing the offers`; `src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts` › `locks the group for update and shares the variants and the offered groups in id order` |
| Writing the offers of an item never touches a variant-scoped offer row | the retirement and the write are scoped to `item_variant_id IS NULL` (K9b), so such a row survives a `PUT` unchanged | `src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts` › `retires only the offers of the whole item and leaves a variant-scoped offer row untouched` |
| Modifier `UPDATE` lists never name an identity, namespace, parent or option target column | `item_id`, `organization_id`, `catalog_id`, `created_at`, `modifier_group_id`, `option_kind`, `item_variant_id` stay out of every `SET` | `src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts` › `%s never names an identity, namespace, parent or target column in its SET list` |
| Six sets of one group sent at the same version; two sets that each read the group and add the same code | one `200` and five `412`, the group equal to the winner; one option with that code, the other set `412` (it read before the first landed) or `400` at `options[3].code` (its body repeats the code it read), never `200`; no deadlock, no `500` | `test/e2e/catalog-modifiers-concurrency.e2e-spec.ts` › `lets six sets of one group named at the same version end with one 200 and five 412, no deadlock and no 500`; `test/e2e/catalog-modifiers-concurrency.e2e-spec.ts` › `lets two sets that each read the group and add the same code ignoring case keep one option with that code, the later one answering 412 when it read before the first landed and 400 when its body repeats the code it read` |
| A rename, a reorder and an option add through the set and an option deactivation of one group sent together | applied one after the other; a set that read an older version is refused whole with `412`; the transition, which carries no `If-Match`, always applies | `test/e2e/catalog-modifiers-concurrency.e2e-spec.ts` › `applies a rename, a reorder, an option add and an option deactivation of one group sent together one after the other, each set naming the version it read and a stale one refused whole` |
| One group offered on six items in parallel; two parallel offers on one item sent with the same `If-Match` | the six `200`; the duel one `200` and one `412 · foundation.precondition_failed`, and the item ends with the winner's list, whole | `test/e2e/catalog-modifiers-concurrency.e2e-spec.ts` › `offers one group on six items in parallel, and serialises two parallel offers on the same item so the one that names the version first wins whole and the other answers 412` |
| An option deactivation and an offer preselecting it, sent together, six rounds | both serialise on the catalog version row; the offer answers `200` or `422 · catalog.default_options_invalid`; no item is left preselecting the inactive option | `test/e2e/catalog-modifiers-concurrency.e2e-spec.ts` › `serialises an option deactivation and an offer that preselects that option, sent together, so no item is left preselecting an inactive option` |
| A default can only name an option of the offered group, catalog and organization; the runtime reads, inserts and deletes defaults but never updates them; another tenant sees none; deleting an offer (only the administrator can since K9c) deletes its defaults | composite foreign keys `item_modifier_group_defaults_offer_fk` and `item_modifier_group_defaults_option_fk`, tenant RLS with the scalar subquery, `ON DELETE CASCADE` (migration `0080`) | `database/tests/contracts/catalog-offer-default-contracts.ts` › `NINAKU_CATALOG_OFFER_DEFAULT_OK` |
| Migration `0082` is the contract step of `0080`: `catalog.modifier_options.is_default` is gone and no index, constraint, trigger or view of the table names it; the copy of `0080`, replayed over the revision 91 column in a rolled back savepoint, still keeps only available defaults in option order up to each maximum and can run again; a retired variant's cleanup reads its options through `modifier_options_variant_idx`, and the items that offer a group are found through `item_modifier_groups_group_idx` | no column, `0` definitions; defaults `o1`, `o1+o2`, none for an inactive group; index scans on 3,000 options and 3,000 offers; no modifier statement names `is_default` | `database/tests/contracts/catalog-offer-default-migration-contracts.ts` › `NINAKU_CATALOG_OFFER_DEFAULT_MIGRATION_OK`; `src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts` › `never names is_default, the option column the contract step of revision 92 dropped` |
| Every variant and barcode command locks the catalog version row, then the item row; parallel adds, default selections and mixed commands of one item | applied one after the other, no deadlock, exactly one default at commit | `test/e2e/catalog-variants-concurrency.e2e-spec.ts` › `adds eight variants to one item in parallel, each once, with no deadlock and no 500`; `test/e2e/catalog-variants-concurrency.e2e-spec.ts` › `serialises two parallel default selections on one item so exactly one default stands at commit`; `test/e2e/catalog-variants-concurrency.e2e-spec.ts` › `applies a variant edit, a barcode set and a default switch of one item sent together, one after the other, with no deadlock, the set answering 412 when the item moved after its tag was read` |
| Two parallel adds of one barcode value on two items | one `201`, one `409 · catalog.barcode_taken` | `test/e2e/catalog-variants-concurrency.e2e-spec.ts` › `lets one of two parallel adds of the same barcode on two items win and refuses the other with catalog.barcode_taken` |
| Sellables-only roles, kitchen, bar, no role and `catalog.items.manage` alone on every assortment route | the same plain `403 · catalog.action_forbidden` at the caller's unit, another unit, an unknown unit and a unit of another organization; commands audited as denied; nothing written | `test/e2e/catalog-assortment-access.e2e-spec.ts` › `answers a cashier, a waiter, a kitchen and a bar member, a member without a role and a holder of catalog.items.manage alone the same plain denial on every assortment route, at its unit, another unit, an unknown unit and a unit of another organization, and writes nothing` |
| A manager anchored at one unit | `200` at its unit; at another unit, an unknown unit and a foreign unit the same plain `403`, decided before any lock or lookup, with one denied record | `test/e2e/catalog-assortment-access.e2e-spec.ts` › `lets a manager anchored at one unit curate and read that unit, and refuses another unit of the organization, an unknown unit and a unit of another organization with the same plain denial, writing nothing there`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `refuse a caller anchored at another unit with one denied record, never locking or looking the unit up` |
| A manager anchored at another unit, at an inactive unit | the same plain `403` as at an active or unknown unit, on every route; nothing written | `test/e2e/catalog-assortment-access.e2e-spec.ts` › `answers a manager anchored at another unit the same plain denial at an inactive unit as at an active or unknown one, writing nothing` |
| Organization-wide holders, `catalog.assortment.manage` alone included; OD3 | any unit `200`; only they learn `404 · organization.structure_not_found`; the assortment holder cannot edit or create items | `test/e2e/catalog-assortment-access.e2e-spec.ts` › `lets an organization-wide owner, manager and holder of catalog.assortment.manage alone curate any unit, tells only them that an unknown unit or a unit of another organization does not exist, and keeps the shared items out of reach of the assortment holder`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `tell only an organization-wide holder that the unit is unknown, before any lock and without a denied record` |
| A member of another organization | its own tenant: `404` like any unknown unit; the victim's tenant: `403 · tenant.context_rejected` | `test/e2e/catalog-assortment-access.e2e-spec.ts` › `answers a stranger signed in to another organization like any unknown unit, and refuses it the tenant of the unit` |
| A member of another organization at a till | its own tenant: `404` like an unknown outlet; the victim's tenant: `403 · tenant.context_rejected` | `test/e2e/catalog-till-access.e2e-spec.ts` › `answers a stranger signed in to another organization like an unknown outlet, and refuses it the tenant of the outlet` |
| Identical item names in two organizations at their tills | each search finds only its own item and answers its own catalog version | `test/e2e/catalog-till-search.e2e-spec.ts` › `keeps identical names of two organizations apart and answers the catalog version of its own organization, never the other one` |
| Every assortment command decides authorization at the unit first, then locks the catalog version row, then shares the items in id order | the lock order of §5; delisting never asks about the plan | `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `decide catalog.assortment.manage with the capability at the business unit before anything else, then lock the catalog version row, read the stored mode, write it and record one change`; `src/modules/catalog/application/assortment/assortment-commands.use-case.spec.ts` › `delist with the plain permission at the unit, never asking about the plan, then lock the version, share the items in id order and write only what changes`; `src/modules/catalog/infrastructure/assortment/assortment.queries.spec.ts` › `shares the curated items in id order` |
| Assortment `UPDATE` lists never name an identity, ownership or namespace column | only `assortment_mode` and `status` | `src/modules/catalog/infrastructure/assortment/assortment.queries.spec.ts` › `%s never names an identity, ownership or namespace column in its SET list` |
| Two parallel mode changes; a mode change and a listing; a listing and an archival; two managers curating two units | all serialised on the catalog version row, no deadlock, no `500`; the listing `200` or `422` as the order decides; each unit holds only its own listings | `test/e2e/catalog-assortment-concurrency.e2e-spec.ts` › `applies two parallel mode changes of one unit one after the other with no deadlock, ending in one of them with one record per real change`; `test/e2e/catalog-assortment-concurrency.e2e-spec.ts` › `serialises a mode change and a listing of one unit sent together, round after round: the listing answers 200 or catalog.assortment_disabled, never a 500`; `test/e2e/catalog-assortment-concurrency.e2e-spec.ts` › `serialises a listing and an archival of the same item sent together, round after round: the listing answers 200 before the archival or catalog.item_archived after it, never a 500`; `test/e2e/catalog-assortment-concurrency.e2e-spec.ts` › `lets two managers curate their own units in parallel with no deadlock, each unit holding only its own listings` |
| An admission shares the catalog version row, then the business unit row (through `OrganizationContextContract.shareBusinessUnit`), then reads; `resolve` locks nothing | the lock order of §5: catalog commands of the organization (another unit's included) wait until the sale's transaction ends, then apply | `test/e2e/catalog-sellable-concurrency.e2e-spec.ts` › `makes an item deactivation, and a delisting and a mode change at another unit, wait until the admitting transaction ends, then applies them`; `src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts` › `share the catalog version row of the tenant and nothing else, leaving the business unit row to Organization`; `src/modules/catalog/application/sellable/catalog-sellables.spec.ts` › `shares the locks before it reads, and answers every line with its index, its snapshot, its quantity and the effective offers holding its selections`; `src/modules/catalog/application/sellable/catalog-sellables.spec.ts` › `locks the version row even when the unit is malformed, and refuses every line as an unavailable unit` |
| A catalog write outside any command, and the Organization module's deactivation of the admitted unit, while an admission is held | both wait: every bumped catalog write updates the shared version row, and the unit row is shared | `test/e2e/catalog-sellable-concurrency.e2e-spec.ts` › `makes a catalog write outside any command and a deactivation of the business unit wait too` |
| An admission arriving while a command holds the version row | it waits, then judges what the command committed (`item_inactive`), never a half state | `test/e2e/catalog-sellable-concurrency.e2e-spec.ts` › `waits for a catalog command that already holds the version row, then judges what it committed, never a half state` |
| Two admissions at one unit | neither waits for the other | `test/e2e/catalog-sellable-concurrency.e2e-spec.ts` › `lets two admissions at the same unit run side by side, because both only share the rows` |
| Admissions, item transitions, listings and mode changes sent together, six rounds | no deadlock, no unexpected error: each admission admitted or refused, each command `200` | `test/e2e/catalog-sellable-concurrency.e2e-spec.ts` › `never deadlocks admissions against item, listing and assortment commands sent together, round after round` |
| Another organization's variant or unit at the contract | the same answer as an unknown one, under the caller's RLS context only | `test/e2e/catalog-sellable-contract.e2e-spec.ts` › `answers another organization variant exactly like an unknown one, and another organization unit, an unknown, an inactive and a malformed unit as the same unavailable unit` |
| Another organization and the menus of an outlet | sees no assignment, deletes none, cannot write one for itself nor link a menu and an outlet of two organizations | `database/tests/contracts/catalog-outlet-menus-contracts.ts` › `another organization sees no assignment of this outlet`; `database/tests/contracts/catalog-outlet-menus-contracts.ts` › `the runtime cannot write an assignment of another organization`; `database/tests/contracts/catalog-outlet-menus-contracts.ts` › `a menu of another organization cannot be assigned to this outlet` |
| Another organization and the hidden items of an outlet | sees none, deletes none, cannot hide an item of its own at this outlet nor an item of this one at its own | `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `another organization sees no hidden item of this outlet`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `the runtime cannot write a hidden item of another organization`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item of another organization cannot be hidden at this outlet`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an outlet of another organization cannot hide an item of this one` |

***

## Images

The catalog side of Assets (`ASSETS_USE_CASES.md` §7.5, slices A5b, A5c
and A6, migration `0095`, baseline revision 105; A6 adds no migration). An image is attached only through
`AssetUsageContract.assertAttachable`, after the version row and the item
are locked, and read only through `AssetDeliveryContract.describe`.

### AC1 — Set an item's images

| Case | Expected answer | Proving test |
|---|---|---|
| Set two images, read them back, replace them, repeat the same list | `200` with the item, `images[]` with the first primary and its rendition URLs, a new tag that `GET` answers too; one `catalog.item.images_set` record per real change with the ids before and after | `test/e2e/catalog-item-images.e2e-spec.ts` › `sets the images of an item with the first as primary, answers the item with its image urls and a new tag, and records the before and after lists`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `locks the version and the item, checks only the new assets, replaces the list and records the lists before and after`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `answers the same list with the stored item, writing and recording nothing` |
| A processing, archived, private, foreign or unknown asset; 11 images; a repeated asset; a waiter; an archived item | `422 · catalog.image_not_attachable` with one `meta.errors[]` entry per asset and its reason, `400`, `400`, `403 · catalog.action_forbidden`, `422 · catalog.item_archived`; nothing written, the version unchanged | `test/e2e/catalog-item-images.e2e-spec.ts` › `refuses a processing, archived, private or foreign asset, more than 10 images and duplicates, and removes images on a lapsed plan`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `refuses an asset that is not attachable naming the item, the asset and the reason, writing nothing` |
| A lapsed plan adds or reorders; it only removes | `403 · platform.capability_not_entitled`; `200` | `test/e2e/catalog-item-images.e2e-spec.ts` › `refuses a processing, archived, private or foreign asset, more than 10 images and duplicates, and removes images on a lapsed plan`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `removes images on a lapsed plan without asking the plan, and refuses adding one` |
| An asset archived while it is being attached | the attach waits for the archive and answers `422` with reason `archived`, nothing attached | `test/e2e/catalog-item-images.e2e-spec.ts` › `serializes attaching an asset with archiving it, so an archived asset is never attached` |
| The old primary moves to another position | no violation of `item_assets_one_item_primary_uq`: the old primary is demoted first | `database/tests/contracts/catalog-item-images-contracts.ts` › `moves the primary image to another asset of the same item without violating the one-primary index`; `src/modules/catalog/infrastructure/items/item-image.repository.integration-spec.ts` › `replaces the images of many items at once, demoting a kept primary before promoting the new first, and the item read answers them with their delivery urls` |
| Each image statement moves the catalog version once when it changed rows, never otherwise | between one and four bumps per command, zero for the same list | `database/tests/contracts/catalog-item-images-contracts.ts` › `bumps the catalog version once per image statement that changed rows, between one and four times per command, and lets the runtime replace an item's image set` |
| Statement budget | 17 statements when the item already has an image (session, membership, `BEGIN`, tenant context, permission, version lock, the one item statement and one `describe`, the capability, `assertAttachable`, delete, demote, upsert, audit, the one item statement and one `describe`, `COMMIT`); 25 before the one-statement item read (API v7 PR 6) | `test/e2e/statement-budget.e2e-spec.ts` › `sets an item's images in the measured statements` |
| A repeated asset, 11 images, an unknown field, a malformed id, an uppercase id (lowercased, so the same asset in two cases repeats), an empty list | `400 · foundation.validation_failed` at the request schema; the empty list is accepted and removes every image | `src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts` › `accepts an empty list, which removes every image, and up to ten distinct images`; `src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts` › `lowercases an uppercase asset id, so the same asset in two cases counts as repeated`; `src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts` › `refuses %s` |
| A repeated asset or 11 images reaching the use case without the schema | refused before any transaction with `catalog` refusal `image_list_invalid`, answered `400 · foundation.validation_failed`; ten distinct images pass | `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `refuses %s at the use case boundary before opening a transaction`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `accepts ten distinct images at the use case boundary` |
| What the client sees for each refusal | `404 catalog.not_found` and `422 catalog.item_archived` / `catalog.image_not_attachable` with the generic title of the status (`Resource not found`, `Unprocessable content`), `retryable: false`, `meta.errors[]` `items.<itemId>.images.<assetId>` with the reason as `code`; `422 catalog.variant_archived` with its own title and no `meta` | `src/modules/catalog/presentation/failures/images-refusal-failure.spec.ts` › `answers %s with %i, the catalog code, the generic title of the status and no retry`; `src/modules/catalog/presentation/failures/images-refusal-failure.spec.ts` › `names each refused asset of an item as items.<itemId>.images.<assetId> with its reason, and a refused item as items.<itemId>`; `src/modules/catalog/presentation/failures/images-refusal-failure.spec.ts` › `reaches the client through the catalog failure mapping, which answers an archived variant with its own 422 and no field errors`; `src/modules/catalog/presentation/failures/images-refusal-failure.spec.ts` › `answers a list refused at the use case boundary like the request schema does, 400 foundation.validation_failed` |
| Another tenant updates, deletes or inserts image rows of a foreign organization | nothing changes; the insert is refused by the row policy (`42501`) | `database/tests/contracts/catalog-item-images-contracts.ts` › `another tenant updates none of the catalog.item_assets rows of a foreign organization`; `database/tests/contracts/catalog-item-images-contracts.ts` › `another tenant deletes none of the catalog.item_assets rows of a foreign organization`; `database/tests/contracts/catalog-item-images-contracts.ts` › `an insert naming a foreign organization is refused by the catalog.item_assets row policy` |
| Each statement kind bumps through its own trigger; a statement that changes nothing does not | `INSERT`, `UPDATE`, upsert and `DELETE` each move the version once; a no-op `UPDATE`, upsert or `DELETE` moves it zero times | `database/tests/contracts/catalog-item-images-contracts.ts` › `one INSERT of two images moves the catalog version once, through the insert trigger`; `database/tests/contracts/catalog-item-images-contracts.ts` › `one UPDATE of two images moves the catalog version once, through the update trigger`; `database/tests/contracts/catalog-item-images-contracts.ts` › `the same upsert again, a no-op, leaves the catalog version as it was`; `database/tests/contracts/catalog-item-images-contracts.ts` › `one DELETE of two images moves the catalog version once, through the delete trigger`; `database/tests/contracts/catalog-item-images-contracts.ts` › `a DELETE that removes no image leaves the catalog version as it was` |

### AC2 — Set a variant's images

Since API v7 PR 4 through `PUT /catalog/items/{itemId}/images` with `variants`.

| Case | Expected answer | Proving test |
|---|---|---|
| Set the images of two variants and of the item in one request | each variant keeps its own list with its own primary beside the item list; one `catalog.item.images_set` and one `catalog.item.variant_images_set` per changed list, naming the variant, variants in id order | `test/e2e/catalog-item-images.e2e-spec.ts` › `sets the item images and the images of each variant sent in one request, keeps a variant not sent, and records one record per changed list`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `replaces the item list and every variant list sent in one transaction, checks every new asset once, keeps a variant not sent, and records one record per changed list`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `sets the images of a variant apart from the item images and records them under the variant action`; `src/modules/catalog/infrastructure/items/item-image.repository.integration-spec.ts` › `keeps the images of a variant apart from the item images, with one primary each` |
| Two pre-populated variants replaced in one call, sharing an asset and swapping each primary | each variant keeps one primary, the item list is untouched | `src/modules/catalog/infrastructure/items/item-image.repository.integration-spec.ts` › `replaces the lists of two pre-populated variants in one call, sharing an asset and swapping each primary, without touching the item list` |
| One primary per variant, untouched by the item list | a second variant primary violates `item_assets_one_variant_primary_uq`; replacing the item list keeps the variant rows | `database/tests/contracts/catalog-item-images-contracts.ts` › `a variant keeps its own primary image apart from the item primary, one primary per variant, untouched by the item list` |
| A variant of another item; an unknown variant; an archived variant | `404 · catalog.not_found`; `404`; `422 · catalog.variant_archived` | `test/e2e/catalog-item-images.e2e-spec.ts` › `sets the item images and the images of each variant sent in one request, keeps a variant not sent, and records one record per changed list`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `refuses an archived item, an unknown variant and an archived variant` |
| A variant not sent; only unchanged lists sent | keeps its images; `200` with the same tag, nothing written or recorded | `test/e2e/catalog-item-images.e2e-spec.ts` › `sets the item images and the images of each variant sent in one request, keeps a variant not sent, and records one record per changed list`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `keeps every variant not sent and records nothing when only unchanged lists are sent` |
| A repeated variant, 101 variants, 11 images in a variant list | `400 · foundation.validation_failed` at `variants[i].variantId` or `variants`, and `image_list_invalid` at the use case boundary | `test/e2e/catalog-item-images.e2e-spec.ts` › `sets the item images and the images of each variant sent in one request, keeps a variant not sent, and records one record per changed list`; `src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts` › `points at the repeated variant by its position`; `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `refuses %s at the use case boundary before opening a transaction` |
| A lapsed plan removes variant images | `200` without asking the plan | `src/modules/catalog/application/items/set-item-images.use-case.spec.ts` › `removes variant images on a lapsed plan without asking the plan` |
| Statement budget | 15 statements plus one audit per changed list, the same for 1 and 4 variants (23 before the one-statement item read) | `test/e2e/statement-budget.e2e-spec.ts` › `sets the images of every variant of an item with a statement count independent of the number of variants except the audits` |

### AC3 — Item read with images

| Case | Expected answer | Proving test |
|---|---|---|
| The item and each variant answer their images | `images[]` and `variants[].images[]` with `assetId`, `isPrimary`, `sortOrder`, `status`, `thumbnailUrl`, `cardUrl`, `detailUrl` | `src/modules/catalog/presentation/dtos/items/item-response.spec.ts` › `answers the images of the item and of each variant in their order, with their status and delivery urls` |
| The tag follows the images | the old `If-None-Match` answers `200` after an image change, the new one `304`; a stale `If-Match` answers `412` | `test/e2e/catalog-item-images.e2e-spec.ts` › `changes the item tag when its images change, so the old If-None-Match answers 200` |
| The repository reads images through the contract the assets module exports | `CatalogModule` imports `AssetsModule`; `ItemRepository` resolves with `AssetDeliveryContract` and reads the URLs through it | `src/modules/catalog/infrastructure/items/item.repository.wiring.spec.ts` › `lets the catalog module import the assets contract module`; `src/modules/catalog/infrastructure/items/item.repository.wiring.spec.ts` › `resolves the item repository with the delivery contract the assets contract module exports, and reads image urls through it` |
| Delivery answers | one `describe` for every image of the item and its variants; an asset the answer does not name keeps `status` and URLs `null`; one statement and no `describe` without images; an item row without a variant is left out; a failing `describe` propagates; no row, no further statement | `src/modules/catalog/infrastructure/items/item-reads.spec.ts` › `describes every image of the item and its variants in one call and fills their status and urls`; `src/modules/catalog/infrastructure/items/item-reads.spec.ts` › `leaves status and urls null for an asset the delivery answer does not name`; `src/modules/catalog/infrastructure/items/item-reads.spec.ts` › `reads the item in one statement and never asks for delivery when it has no image`; `src/modules/catalog/infrastructure/items/item-reads.spec.ts` › `leaves out an item row that comes back without a variant`; `src/modules/catalog/infrastructure/items/item-reads.spec.ts` › `lets a delivery failure propagate instead of answering the item without its images`; `src/modules/catalog/infrastructure/items/item-reads.spec.ts` › `answers nothing and asks nothing more when no item row comes back` |
| The item read is one statement (API v7 PR 6) | one item, a locked item and many items by a uuid array are each one statement that nests the variants with their live barcodes, offers and images, the categories, the item offers with their options and defaults, and the item images aggregated once per item; every child reached by equality on its keys, no `EXISTS`, no `OFFSET` | `src/modules/catalog/infrastructure/items/item-read.queries.spec.ts` › `%s reads one item by its id, its only parameter`; `src/modules/catalog/infrastructure/items/item-read.queries.spec.ts` › `reads many items binding their ids as one uuid array, its only parameter`; `src/modules/catalog/infrastructure/items/item-read.queries.spec.ts` › `nests every child list inside the item row, so one statement answers the whole item`; `src/modules/catalog/infrastructure/items/item-read.queries.spec.ts` › `aggregates the images of the item once per item and those of a variant once per variant, by their keys`; `src/modules/catalog/infrastructure/items/item-read.queries.spec.ts` › `reaches every child by equality on its keys, never by EXISTS nor OFFSET` |
| Each list of the item read keeps its order whatever the collation, and each offered group is built once | variants `is_default DESC, code, id`; barcodes `status, is_primary DESC, barcode, id`; offers `sort_order, id`; options and default options `sort_order, lower(code), id`; categories `is_primary DESC, lower(name), id`; images `sort_order, id`; the options and facts of a group are read once per item | `src/modules/catalog/infrastructure/items/item-read.queries.spec.ts` › `orders the %s by %s, whatever the collation of the database`; `src/modules/catalog/infrastructure/items/item-read.queries.spec.ts` › `builds the facts and the options of each offered group once per item, never once per offer` |
| The item representation is byte for byte the one before the one-statement read | a rich item (three variants, live, inactive and archived barcodes, item and variant images, item offers with options and defaults, a variant override, an inactive and a retired offer, two categories) answers the golden file key by key, and read, lock and read-many answer the same entity tag | `src/modules/catalog/infrastructure/items/item-read.golden.integration-spec.ts` › `reads the item with its variants, live barcodes, offers with their options and defaults, categories and delivered images exactly as the golden file holds them`; `src/modules/catalog/infrastructure/items/item-read.golden.integration-spec.ts` › `locks, reads among many and reads alone the same representation, so the entity tag of every path is the same` |
| The item read stays keyed under stale statistics | the same catalog imported into a neighbour before `ANALYZE` and into the measured organization after it (2,001 items with offers, barcodes, images and 500 categories): a 40-variant item reads at most 1,000 rows and 2,000 buffers and no lookup wider than 50 rows, 100 items at most 4,000 rows and 10,000 buffers, an item of 500 variants each overriding the same 20 groups, locked, at most 40,000 rows and 50,000 buffers; no sequential scan, no looped scan wider than 50 rows | `src/modules/catalog/infrastructure/items/item-read.plan.integration-spec.ts` › `reads an item of 40 variants with their barcodes, images and offers by their keys, never scanning a whole table`; `src/modules/catalog/infrastructure/items/item-read.plan.integration-spec.ts` › `reads 100 imported items at once by their keys, never scanning a whole table`; `src/modules/catalog/infrastructure/items/item-read.plan.integration-spec.ts` › `locks an item of 500 variants that each override the same 20 groups by their keys, within the measured bound` |

### AC4 — Till read with images

C17 and C19 (slice A6, no migration): `imageRef` and `aiGenerated` on every
item, on a variant only when it has a primary image of its own, and one
`meta.media.tileUrlTemplate`; one `AssetDeliveryContract.describe` per
answer, only when some listed item or variant has an image.

| Case | Expected answer | Proving test |
|---|---|---|
| An item with a primary image and one without; the image set; another organization sets an image | the first answers `imageRef` `<token>/p1` and `aiGenerated: false`, the second `imageRef: null`; one `meta.media.tileUrlTemplate`; the asset id never appears; the tag moves and the old `If-None-Match` answers `200`; the other organization's write leaves the tag at `304` and its token never appears | `test/e2e/catalog-till-images.e2e-spec.ts` › `answers each sellable item with its image reference and one tile url template, and changes the till tag when an image is set, never when another organization's image changes` |
| A variant with its own primary image; a variant without one | the variant answers its own `imageRef` and `aiGenerated` (true for an AI image); the other carries no image keys and shows the item image | `test/e2e/catalog-till-images.e2e-spec.ts` › `answers a variant with a primary image of its own beside the item image, and no image keys on a variant that shows the item image`; `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `answers each item and variant with the image reference of its own primary image and one tile url template, describing every image in one call` |
| The media host changes | a new tag (the delivery fingerprint), the old `If-None-Match` answers `200` with the new template, the new tag `304` | `test/e2e/catalog-till-images.e2e-spec.ts` › `changes the till tag and the tile url template when the media host changes, while the same tag still answers 304`; `src/modules/catalog/application/till/sellable-entity-tag.spec.ts` › `includes the delivery fingerprint so a new public base url gives another tag` |
| An attached asset that is processing, private, or archived | `imageRef: null` for the first two (their tokens never appear); the archived one keeps its `imageRef` (archiving never changes delivery, `ASSETS_USE_CASES.md` §6.6) | `test/e2e/catalog-till-images.e2e-spec.ts` › `never answers an image reference for an attached asset that is not ready or not public, and keeps delivering an archived one` |
| No listed item or variant has an image; a matching `If-None-Match` | no `describe` call; the `304` is decided before any item or image is read | `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `describes nothing when no listed item or variant has an image`; `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `answers a current tag carrying the delivery fingerprint before reading any item or image` |
| C19 pages | each page item with its `imageRef`, the template in `meta.media`, one `describe` per page | `test/e2e/catalog-till-images.e2e-spec.ts` › `pages what an outlet sells with the image reference of each item and the tile url template`; `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `answers a search page with the image references of its items, describing the page once` |
| Statement budget with images | C17 `200` 9, `304` 7, C19 9 | `test/e2e/statement-budget.e2e-spec.ts` › `reads what an outlet sells with images in 9 statements and answers 304 in 7` |
| Each image link change; an attached asset archived | a new tag after every attach, reorder and detach; after the archival the same tag (`304`) and the same `imageRef` (the ETag invariant of "A6 as built") | `test/e2e/catalog-till-images.e2e-spec.ts` › `moves the till tag on every image link change and keeps the tag and the image reference when an attached asset is archived` |
| A second primary for an item or a variant; an item with a primary, three gallery images and a variant with its own primary | `23505` naming `item_assets_one_item_primary_uq` and `item_assets_one_variant_primary_uq`; the item once in C17, at a `limit` cut and across C19 pages | `src/modules/catalog/infrastructure/till/till-read.repository.integration-spec.ts` › `refuses a second primary image for the same item or the same variant, the uniqueness the till joins rely on`; `src/modules/catalog/infrastructure/till/till-read.repository.integration-spec.ts` › `answers an item with a primary, three gallery images and a variant with its own primary once in the list and once across the search pages, with the limit boundary intact` |
| The wire shape of a variant without, with an undeliverable and with a delivered own image; an item | no keys; `null` and `false`; its reference; an item always both keys | `src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts` › `omits the image keys on a variant without a primary of its own, answers null and false for an undeliverable one and its reference otherwise`; `src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts` › `always answers both image keys on an item, null and false without an image` |
| The strict schemas and `meta.media` | the new keys validate, a missing `imageRef` or a non-boolean `aiGenerated` is refused; `tileUrlTemplate` a string or `null`, never absent | `src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts` › `validates the new keys against the strict response schemas`; `src/modules/catalog/presentation/dtos/till/sellable-response.spec.ts` › `answers the tile url template in meta.media, or null while the media host is unset` |
| An outlet no unit operates today; an outlet selling 2,001 items | empty lists, the template and a `-none-d…` tag taken from the values of the statement itself, no `describe`; one `describe` for the 2,000 answered items only | `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `answers an outlet no business unit operates today with the empty lists, the template and a -none tag its statement returned, describing no image`; `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `describes only the images of the 2,000 answered items when the outlet sells more` |

### AC5 — Set images for many items at once

| Case | Expected answer | Proving test |
|---|---|---|
| Five items set twice | `200` with every item and the tag `GET` answers, in request order; the version moves once, then at most four times; one record per changed item | `test/e2e/catalog-item-images.e2e-spec.ts` › `sets the images of many items in one request moving the catalog version at most four times whatever the number of items, and refuses the whole request when one asset is not attachable`; `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `locks the items in id order, checks every new asset once, writes every changed list in one call and audits each changed item`; `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `writes and audits nothing for an item whose list did not change` |
| One asset not attachable; an unknown item; an archived item | `422 · catalog.image_not_attachable` naming each item and asset, `404 · catalog.not_found` and `422 · catalog.item_archived` naming each item; nothing written | `test/e2e/catalog-item-images.e2e-spec.ts` › `sets the images of many items in one request moving the catalog version at most four times whatever the number of items, and refuses the whole request when one asset is not attachable`; `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `refuses the whole request naming each item and asset when one asset is not attachable`; `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `refuses unknown items before archived ones, naming each, and writes nothing` |
| A lapsed plan | removal only `200`; an addition `403 · platform.capability_not_entitled` | `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `removes images on a lapsed plan and refuses the request when one item adds an image` |
| Statement budget | 15 statements plus one audit per changed item, the same for 1 and 100 items (19 before the one-statement item read) | `test/e2e/statement-budget.e2e-spec.ts` › `sets images for 100 items with a statement count independent of the number of items except the audits` |
| Zero items, 101 items, a repeated item (also in two cases), a repeated asset or 11 images in one list | `400 · foundation.validation_failed` at the schema, and the same refusal at the use case boundary before any transaction | `src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts` › `accepts one to a hundred distinct items, each with an empty or a full list`; `src/modules/catalog/presentation/dtos/items/item-images-request.schema.spec.ts` › `refuses %s`; `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `refuses %s at the use case boundary before opening a transaction` |
| An entry with the current tag of its item; one with a stale tag (its list unchanged); a wildcard `ifMatch` | `200`; `412 · foundation.precondition_failed` naming `items.<itemId>` with `precondition_failed`, nothing written, the version unchanged; `400` at `items[0].ifMatch` | `test/e2e/catalog-item-images.e2e-spec.ts` › `writes a bulk entry carrying the current tag of its item and refuses the whole request naming every item whose tag is stale`; `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `checks the tag of each item sent with ifMatch against the locked item and writes when it matches`; `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `refuses the whole request naming every item whose ifMatch is stale, even when its list is unchanged, writing nothing` |
| An unknown item and a stale tag; an archived item and a stale tag | `404` first; `412` before `422` | `src/modules/catalog/application/items/set-many-item-images.use-case.spec.ts` › `refuses an unknown item before comparing tags, and a stale tag before an archived item` |

## Combos

Slice B1 (migration `0100`, baseline revision 110): the composition of a
combo, kit or package (`CATALOG_USE_CASES.md`, "B1 as built"). A combo is a
normal item that owns bundle groups; prices stay with Pricing.

### C22 — Set and read what a combo is made of

| Case | Expected answer | Proving test |
|---|---|---|
| Make an item a combo with three groups (Principal, Bebida, Acompañamiento), read it back, revalidate | `200` with the groups and their choices in order, each choice with its item name, variant, SKU, statuses, `quantityPerSelection`, `maxSelectionCount`, `isDefault`, `suspended` and `isAvailable`; `GET` answers the same body and strong tag; `If-None-Match` answers `304`; one `catalog.item.bundle_groups_set` record | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `makes an item a combo with three groups, answers the names in order and reads back the same with the same ETag`; `src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts` › `locks the version then the combo, judges the components, writes, re-reads and records the composition before and after` |
| Reorder groups, drop a choice, send it again | every kept group and choice keeps its id; the dropped choice is `retired` (the row stays), and sending its variant again revives the same id; a choice sent without `choiceId` keeps its id when its variant is already in the group | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `keeps every id on a reorder, retires a removed choice without deleting it and revives it with the same id`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `keeps the id of every group and choice the client sends back, in the order sent`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `keeps the id of a choice sent without its id, and revives a retired one with its old id`; `src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts` › `writes a combo, keeps every id on a rewrite, retires instead of deleting and reads back names and statuses in order` |
| New groups and choices; a group without a code | only they get new ids; a new group without `code` takes one derived from its name (`postre-del-dia`), the first free against every code the combo holds or held and the codes sent (`bebida-2`), `group` when the name has no letter or digit; a kept one keeps its code | `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `gives a new id only to a new group and a new choice, and derives the code of a new group from its name`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `derives the first free code, skipping the codes of every stored group, retired ones included, and the codes sent`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `falls back to group when the name holds no letter or digit`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `keeps the stored code of a kept group when the client omits it` |
| Send the composition the combo already has (`quantityPerSelection` `1.0` for `1` included) | `200`, the same body and tag, no write, no record, the catalog version unchanged, the plan never asked | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `answers the same composition again without a write, a record or a catalog version change`; `src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts` › `answers the stored composition when nothing changes, writing and recording nothing and never asking the plan`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `finds the same composition when every group and choice comes back unchanged`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `treats an equal quantity written another way as the same`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `sees a change in the order, a rule, a quantity, a cap, a default, a suspension or a new group` |
| Rename a group and change nothing else | one `catalog.item.bundle_groups_set` record whose `before` and `after` differ and name the group as it was and as it is: the summary and the change check come from one key, so they cannot drift | `src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts` › `records a rename of a group alone with different before and after summaries that both name it`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `lists each live group with its rule, its status, its name and its choices, and nothing when there are none`; `test/e2e/catalog-audit-renames.e2e-spec.ts` › `records the old and the new name of a combo group renamed and nothing else` |
| A stale `If-Match`; the current one | `412 · foundation.precondition_failed` before anything is judged or written; `200` | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `refuses a stale If-Match with 412 and writes with the current one`; `src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts` › `refuses a stale If-Match before anything is judged or written, and accepts the current tag` |
| A combo inside a combo; a loop; the combo itself | `200`; `422 · catalog.bundle_cycle` with `meta.errors[]` at `bundleGroups[i].choices[j].variantId`; the SQL guard (`bundle_composition_acyclic`) is the backstop and maps to the same code | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `allows a combo inside a combo and refuses a loop or the combo itself, naming the field`; `src/modules/catalog/domain/bundles/bundle-judgement.spec.ts` › `refuses the combo itself or a combo that contains it as a choice`; `src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts` › `refuses a cycle and an archived component with typed refusals pointing at the variant, writing nothing`; `src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts` › `maps the SQL cycle guard to a cycle refusal when a write would close a loop`; `src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts` › `marks a component that contains the combo, directly or through nesting`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `a combo cannot contain, through nesting, a combo that contains it`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `an item cannot be a choice of its own group (bundle_composition_acyclic)` |
| A new choice whose item or variant is archived, or of another catalog; one already in the group archived since | `422 · catalog.bundle_component_unavailable` naming the field; the kept one stays | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `refuses an archived variant as a new choice and shows a deactivated one as unavailable`; `src/modules/catalog/domain/bundles/bundle-judgement.spec.ts` › `refuses a new choice whose item or variant is archived or lives in another catalog, but keeps one already offered` |
| A deactivated component in a required group; caps that cannot reach the minimum; a suspended required group | `isAvailable: false` on the choice and `isSatisfiable: false` on the group; an optional group stays satisfiable; a suspended required group is never satisfiable; the same rule B2 judges | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `refuses an archived variant as a new choice and shows a deactivated one as unavailable`; `src/modules/catalog/domain/bundles/bundle-satisfiability.spec.ts` › `always satisfies an optional group, even suspended and without an available choice`; `src/modules/catalog/domain/bundles/bundle-satisfiability.spec.ts` › `counts each available choice at its cap and refuses when they cannot reach the minimum`; `src/modules/catalog/domain/bundles/bundle-satisfiability.spec.ts` › `never satisfies a required group that is suspended`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts` › `tells the manager a suspended required group leaves the combo unsellable and none of its choices can be picked` |
| An unknown group, choice or variant; a choice id with another variant; a code another group holds or held | `404 · catalog.not_found`, `422 · catalog.bundle_choice_mismatch`, `409 · catalog.code_taken`, each with `meta.errors[]` naming the field and how to fix it; the first kind by precedence wins | `src/modules/catalog/domain/bundles/bundle-judgement.spec.ts` › `points at an unknown group, choice or variant with not_found`; `src/modules/catalog/domain/bundles/bundle-judgement.spec.ts` › `refuses a choice id sent with another variant than the one it names`; `src/modules/catalog/domain/bundles/bundle-judgement.spec.ts` › `refuses a code another group of the item holds, retired groups included`; `src/modules/catalog/presentation/failures/bundle-refusal-failure.spec.ts` › `answers %s with %i and %s, pointing meta.errors at the field with a message that says how to fix it`; `src/modules/catalog/presentation/failures/bundle-refusal-failure.spec.ts` › `answers the kind that comes first when several are refused, listing only its fields` |
| Another organization; a cashier; a foreign variant; min above max; 21 groups | `404`; `403 · catalog.action_forbidden`; `404` at `bundleGroups[0].choices[0].variantId`; `400` at `bundleGroups[0].minSelections`; `400` at `bundleGroups` | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `answers 404 for another organization, 403 to a member without the permission and 400 with pointers for broken rules` |
| Rules that need no lookup | `400 · foundation.validation_failed` with the field: minimum above maximum, a group without choices, a variant twice in a group, defaults outside the minimum and the maximum, a `quantityPerSelection` that is not a decimal above 0 and at most 999, a `maxSelectionCount` above the group maximum or caps that cannot reach the minimum, a suspended default, a repeated group, code or choice, more than 20 groups or 50 choices, an unknown field such as a price; an empty list is accepted | `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `points at a minimum above the maximum, a group without choices and a repeated variant`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `points at defaults outside the minimum and the maximum, and accepts none`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `refuses a zero, negative, too large or non-decimal quantity per selection`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `refuses a repeated group, code or choice id, more than 20 groups, more than 50 choices and unknown fields`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `accepts an empty list, which makes the item plain again`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `accepts the documented example and fills the defaults of a choice` |
| A lapsed plan | an empty list `200`; any other change `403 · platform.capability_not_entitled`, nothing written | `src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts` › `removes every group on a lapsed plan, and refuses adding one`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `needs no plan only to remove every group` |
| An unknown or archived combo | `404 · catalog.not_found`; `422 · catalog.item_archived` | `src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts` › `answers not found for an unknown item and refuses an archived one` |
| Read | any item, archived included, after `catalog.items.view`; a plain item answers `[]`; retired groups and choices are not shown; nothing locked or recorded; `403` before reading | `src/modules/catalog/application/bundles/read-bundle-groups.use-case.spec.ts` › `reads the composition of any item, archived ones included, after the view permission, without locking or recording anything`; `src/modules/catalog/application/bundles/read-bundle-groups.use-case.spec.ts` › `answers not found for an unknown item and forbidden without the permission, before reading`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts` › `shows only live groups and choices, with the cap and the suspension of each`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts` › `documents an example that the schema accepts`; `src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts` › `reads nothing of another organization` |
| Statement budget | `PUT` 16 statements whatever the number of groups (session, membership, `BEGIN`, tenant context, permission, version lock, composition lock, capability, components, retire groups, upsert groups, retire choices, upsert choices, re-read, audit, `COMMIT`), 8 for an unchanged composition, `GET` 7 | `test/e2e/statement-budget.e2e-spec.ts` › `sets and reads the bundle groups of a combo in the measured statements, whatever the number of groups` |
| Runtime privileges and version triggers | `SELECT`, `INSERT`, `UPDATE`, never `DELETE` nor `TRUNCATE`, on both tables; `EXECUTE` on the two graph functions; one bump per statement kind on insert, update and delete; a no-op upsert and an `UPDATE` that changes nothing do not bump | `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `one INSERT of two choices moves the catalog version once`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `the same upsert again, a no-op, leaves the catalog version as it was`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `an UPDATE that changes no choice leaves the catalog version as it was`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `the runtime cannot delete a choice, not even its own`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `the runtime cannot delete a bundle group, not even its own`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `retiring a group is an UPDATE that moves the version once` |
| Another tenant | sees and updates none of either table; a group naming a foreign organization is refused (`42501`) | `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `a bundle group naming a foreign organization is refused`; `src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts` › `reads nothing of another organization` |
| A group, code or choice sent twice, a variant twice in a group | `400 · foundation.validation_failed` with one `meta.errors[]` entry per repetition, pointing at the repeated element (`bundleGroups[1].bundleGroupId`), before the use case runs; the use case no longer judges repetitions again | `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `refuses a repeated group, code or choice id, more than 20 groups, more than 50 choices and unknown fields`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `points at a minimum above the maximum, a group without choices and a repeated variant` |
| An otherwise unchanged composition with an unknown choice id or one of another variant | `404 · catalog.not_found` or `422 · catalog.bundle_choice_mismatch`, never the stored composition | `src/modules/catalog/domain/bundles/bundle-judgement.spec.ts` › `judges the ids the client sends without reading any component, so an unchanged composition is judged too`; `src/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts` › `judges the ids sent back even when nothing else changes: an unknown choice id is not found and an id of another variant a mismatch`; `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `refuses every change but emptying the combo on a lapsed plan, and judges the ids of an unchanged composition` |
| A lapsed plan over HTTP | a new group `403 · platform.capability_not_entitled`; an empty list `200` | `test/e2e/catalog-bundle-groups.e2e-spec.ts` › `refuses every change but emptying the combo on a lapsed plan, and judges the ids of an unchanged composition` |
| A retired choice and the cycle guard | a retired choice or group does not count, so the contained combo may contain the other; reviving the retired row is refused (`bundle_composition_acyclic`); the use case lookup agrees | `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `a retired choice does not count for the cycle guard, so the contained combo may now contain the other`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `reviving the retired choice would close a loop and is refused (bundle_composition_acyclic)`; `database/tests/contracts/catalog-bundle-groups-contracts.ts` › `a retired group does not count either, and reviving it is checked like reviving a choice`; `src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts` › `ignores a retired choice when it looks for the combos that contain one, like the SQL cycle guard` |
| The locking read | runs against PostgreSQL and answers what the plain read answers, retired rows included; an unknown item answers nothing | `src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts` › `locks the combo with its composition in the same statement and answers what the read answers, retired rows included` |
| Send back the groups `GET` answers, a `null` cap included | `200`, the same body and tag, no record and no catalog version change | `test/e2e/catalog-bundle-suspension.e2e-spec.ts` › `takes back the groups GET answers, a null cap included, as the same composition: 200, the same body and tag, no record and no version change`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `reads a null cap, as GET answers it, as no cap` |
| Suspend, cap and resume through `PUT` | `suspended: true` writes the group or choice `inactive` and answers `suspended: true`, `isAvailable: false`; `maxSelectionCount` is written and answered; sending it again without the flag resumes it with the same id; a retired row comes back as sent | `test/e2e/catalog-bundle-suspension.e2e-spec.ts` › `writes a suspended choice and a cap, answers them, and resumes the choice keeping its id`; `src/modules/catalog/infrastructure/bundles/bundle-group.repository.integration-spec.ts` › `writes the status and the cap the plan sends: a suspended group resumes, a choice is suspended and capped`; `src/modules/catalog/domain/bundles/bundle-composition.spec.ts` › `writes a suspended group or choice as inactive, and an offered one as active, with the cap of each choice`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `accepts a suspended group and a suspended choice, but never a suspended default`; `src/modules/catalog/presentation/dtos/bundles/bundle-groups-request.schema.spec.ts` › `refuses a cap that is not a positive integer or exceeds the maximum of its group, and caps that cannot reach the minimum` |
| Extra application fields | never reach the answer: a group and a choice carry exactly their documented keys | `src/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts` › `answers exactly the documented fields of a choice, whatever else the application carries` |
| Write failures | the cycle guard, the code index and the two variant foreign keys map to typed refusals keeping the cause; any other failure passes through | `src/modules/catalog/infrastructure/bundles/bundle-write-failure.spec.ts` › `maps %s on %s to a %s refusal that keeps the database error as its cause`; `src/modules/catalog/infrastructure/bundles/bundle-write-failure.spec.ts` › `lets any other failure through unchanged, a violation of another constraint included` |

### C28 — Admit a combo with the components the customer chose

Slice B2 (no migration, baseline revision 110): `admit` of
`CatalogSellableContract` takes `bundleSelections` on a line and answers the
tree a sale stores (`CATALOG_USE_CASES.md`, "B2 as built";
[`CATALOG_OPTIONS_V2.md`](CATALOG_OPTIONS_V2.md) §20).

| Case | Expected answer | Proving test |
|---|---|---|
| A combo with a chosen burger (with its cooking option), fries twice and the drink group left out | admitted; `bundleGroups` in catalog order, each choice with `choiceId`, `quantity`, `quantityPerSelection`, `appliedByDefault`, its own snapshot and admitted offers; the default drink applied once; the sales schema stores the rows built from the answer alone and refuses them without the drink | `test/e2e/catalog-sellable-bundles.e2e-spec.ts` › `admits a combo with its chosen components and their modifiers, applies the default of a group left out, and the sales schema stores the tree it answers`; `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `admits the chosen choices with their modifiers, applies the default of a group left out and answers the tree a sale stores, reading the components in one more statement`; `src/modules/catalog/domain/sellable/bundle-admission.spec.ts` › `resolves every chosen choice of a sent group in request order, with its quantity, its modifier and nested selections and the path that points at it`; `src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts` › `applies the available defaults of a group the client left out, once each, pointing at where the group would be sent` |
| The same choice sent twice in a group; quantities summed against the minimum and the maximum | two occurrences, as two Sales rows; `bundle_selection_below_minimum` or `bundle_selection_above_maximum` on the sum | `src/modules/catalog/domain/sellable/bundle-admission.spec.ts` › `judges the minimum and the maximum of a group on the sum of the chosen quantities, a choice sent twice counting twice`; `src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts` › `accepts the same choice twice in a group, as two occurrences` |
| A sent group that leaves its default out | the choice sent is admitted and the available default is answered in `removedDefaults`; a default no longer available is never named | `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `names the default a sent group left out as removed and admits the choice the client made instead`; `src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts` › `names the available defaults a sent group left out as removed, never one it chose nor one no longer available` |
| A combo sent without `bundleSelections` whose required group has no default | `bundle_selection_below_minimum` for that group at `lines[0].bundleSelections`, with `bundleGroupId` and a `detail` | `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `refuses a combo sent without choices when a required group has no default, pointing at where the group belongs`; `src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts` › `refuses a required group the client left out when it has no default to apply, naming the group at the list it belongs to` |
| A group left out whose default is delisted at the unit or hidden at the outlet, in an optional and in a required group, with and without `outletId` | the default is not applied and nothing else takes its place: an optional group is admitted with no choice, a required one refuses `bundle_selection_below_minimum` at `lines[0].bundleSelections`; the combo still resolves as sellable while another choice is available; a chosen default that is no longer sold still refuses `not_listed`; hidden at the outlet only counts when `outletId` is sent | `test/e2e/catalog-combo-defaults-context.e2e-spec.ts` › `applies no default that is no longer sold ($unavailability.name, outlet $outlet, minimum $minSelections), keeps refusing a chosen one and never swaps the drink` |
| An unlisted component, a component modifier group left out, a choice of another group, a group above its maximum | `not_listed` and `selection_below_minimum` (with `modifierGroupId`) at the path of the choice, `bundle_choice_not_offered`, `bundle_selection_above_maximum`; nothing admitted | `test/e2e/catalog-sellable-bundles.e2e-spec.ts` › `refuses a combo without its required choices, an unlisted component, a component modifier left out, a choice of another group and a group above its maximum, each with a reason, the path to fix and nothing admitted`; `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `refuses a component the business unit does not sell with its own sellable reason, and a component modifier group left below its minimum, at the path of the choice`; `src/modules/catalog/domain/sellable/bundle-admission.spec.ts` › `refuses a choice the group does not offer, retired or of another group, and a suspended one, and keeps them out of the sum` |
| An unknown, retired or suspended group; groups sent to a plain item | `bundle_group_not_offered`, `bundle_group_unavailable`, at `lines[i].bundleSelections[g]` | `src/modules/catalog/domain/sellable/bundle-admission.spec.ts` › `refuses an unknown or retired group as not offered and a suspended one as unavailable, pointing at each`; `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `refuses choices sent to a plain item as groups the item does not offer` |
| A suspended choice; a choice above its cap; a required group whose components are all inactive; a suspended required group left out or sent; a suspended optional group left out | `bundle_choice_unavailable`; `bundle_choice_above_maximum`; `bundle_group_unsatisfiable` without judging its sum, as B1's `isSatisfiable`; `bundle_group_unavailable` at the list or at the sent group; nothing for the optional one | `test/e2e/catalog-bundle-suspension.e2e-spec.ts` › `refuses at admission a suspended choice, a choice above its cap, and a suspended required group whether it is sent or left out`; `test/e2e/catalog-sellable-bundles.e2e-spec.ts` › `refuses a suspended choice, and a combo whose required group has no active component, as the manager screen shows it`; `src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts` › `refuses a required group whose live choices cannot reach its minimum as unsatisfiable, without judging its sum`; `src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts` › `neither requires nor answers an optional suspended group the client left out`; `src/modules/catalog/domain/sellable/bundle-admission.spec.ts` › `refuses a required suspended group the client leaves out as unavailable, at the list, and ignores an optional one`; `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `refuses a combo whose only required group is suspended, even when the client sends nothing` |
| A choice chosen more times than its `max_selection_count` | `bundle_choice_above_maximum` at its first entry, the entries summed | `src/modules/catalog/domain/sellable/bundle-admission.spec.ts` › `refuses a choice chosen more times than its own cap, summing every entry of that choice` |
| A combo inside a combo | the inner combo judged with its own groups and defaults, answered under its choice; the sales schema stores the nested rows | `test/e2e/catalog-sellable-bundles.e2e-spec.ts` › `admits a combo inside a combo, judging and defaulting the inner groups, and the sales schema stores the nested tree`; `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `judges a nested combo with its own groups and defaults, one statement per level whatever the number of groups` |
| Nesting deeper than 3, in the request or through defaults; exactly 3 | request problem `too_deep` before any lock; `bundle_too_deep` at the list, at most one read per allowed level; admitted | `src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts` › `rejects a combo choice quantity outside 1 to 999, a group sent twice at one level, an option repeated in a component, too many components or groups and nesting deeper than the bound, before any lock`; `src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts` › `accepts nesting exactly to the depth bound and exactly the component limit, and refuses one level or one component more`; `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `refuses combos nested deeper than the bound, reading at most one statement per allowed level`; `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `admits combos nested exactly to the bound` |
| A choice quantity off 1 to 999, a group twice at one level, an option twice in a component, more than 100 components or 20 groups; defaults beyond 100 components | `invalid_quantity`, `duplicate`, `too_many_components` before any lock; `bundle_components_above_limit` at `lines[i]` | `src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts` › `rejects a combo choice quantity outside 1 to 999, a group sent twice at one level, an option repeated in a component, too many components or groups and nesting deeper than the bound, before any lock`; `src/modules/catalog/application/sellable/catalog-sellables-request.spec.ts` › `accepts nesting exactly to the depth bound and exactly the component limit, and refuses one level or one component more`; `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `refuses a line whose defaults expand beyond the component limit` |
| Defaults that fan out past 100 components through nested combos (20 groups of defaults on each level) | `bundle_components_above_limit` at `lines[i]`, the only refusal; the line stops expanding at the level where it passes 100, reads no deeper level and at most 100 components, while the other lines of the admission keep expanding | `src/modules/catalog/application/sellable/catalog-sellables-component-limit.spec.ts` › `stops expanding a line as soon as its defaults pass the component limit, reading no deeper level`; `src/modules/catalog/application/sellable/catalog-sellables-component-limit.spec.ts` › `keeps expanding the other lines of the admission while one line passes the limit`; `test/e2e/catalog-bundle-fan-out.e2e-spec.ts` › `refuses at admission a combo whose nested defaults pass the component limit, at the line` |
| A plain line, or an item whose groups are all optional and suspended (a required suspended group refuses `bundle_group_unavailable` instead) | answered with exactly `lineIndex`, `snapshot`, `quantity`, `offers`, no `bundleGroups` key, and no extra statement | `src/modules/catalog/application/sellable/catalog-sellables-bundles.spec.ts` › `answers no bundle groups on a plain line, byte for byte as before, and reads nothing more`; `src/modules/catalog/domain/sellable/bundle-admission-defaults.spec.ts` › `says an item is a combo only while one of its groups is active` |
| The groups a variant's item owns | read in `SELLABLE_FACTS` itself: live groups with live choices, names, `quantityPerSelection` in the shortest form, cap, default, statuses, `componentActive` (item and variant active) and `componentOffered` (also listed at the unit, not hidden at the outlet sent and configurable: the rule the till applies to a choice, without its own nested composability, which the nested descent explains); `[]` for a plain item; the snapshot unchanged | `src/modules/catalog/infrastructure/sellable/sellable.repository.integration-spec.ts` › `reads the live groups of a combo with their live choices, names, quantities, caps, defaults and statuses in the same statement, and none for a plain item`; `src/modules/catalog/infrastructure/sellable/sellable.queries.spec.ts` › `read the live bundle groups of each variant item in the same statement, leaving retired groups and choices out` |
| The contract surface | every type of the tree and of `BundleAdmissionRefusal` is exported by the contract | `src/modules/catalog/contracts/catalog-sellable.contract.spec.ts` › `names every type of a combo admission, so the tree needs no catalog/domain import either` |
| A composition change while a sale admits the combo | the `PUT` waits for the admitting transaction (one catalog version share lock), then applies | `test/e2e/catalog-sellable-bundles.e2e-spec.ts` › `makes a composition change of the combo wait until the admitting transaction ends, then applies it` |
| Statement budget | 6 statements for a plain line, 7 for a combo with three groups, 8 for a combo inside a combo: one per level of components | `test/e2e/statement-budget.e2e-spec.ts` › `admits a plain line, a combo with three groups and a combo inside a combo in the measured statements, one more per level of components whatever the number of groups` |

## Menus

Slice M1 (migration `0101`, baseline revision 111): the structure of a menu
(`CATALOG_USE_CASES.md`, "M1 as built"). A menu says what is shown; which
outlet shows it is M2a (C29, migration `0105`, baseline revision 115),
its time windows S1 (C31, migration `0107`, baseline revision 117), and prices stay with Pricing.

### C21 — Build and read the structure of a menu

| Case | Expected answer | Proving test |
|---|---|---|
| Create a draft menu, build Entradas with Frías, Principales (two selected sizes) and Postres, read it back, revalidate | `201` empty `draft`; `200` with the sections, subsections and entries in order, item and variant names, `variantMode`, `isAvailable`, `entryCount` and `availableEntryCount`; `GET` answers the same body and strong tag; `If-None-Match` answers `304`; one `catalog.menu.structure_set` record | `test/e2e/catalog-menus.e2e-spec.ts` › `creates a draft menu, builds Entradas with Frías, Principales and Postres, reads it back with the same ETag and revalidates with 304`; `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `locks the version then the menu, judges the items, writes, re-reads and records the structure before and after` |
| Reorder sections, move a subsection to the top and a section under it, drop an entry, send it again | every kept section and entry keeps its id; the dropped entry and section are `retired` (the rows stay), and sending the item again revives the same id | `test/e2e/catalog-menus.e2e-spec.ts` › `keeps every id on a reorder and a move, retires a removed entry without deleting it and revives it with the same id`; `src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts` › `writes nested sections and entries, keeps every id on a rewrite, retires instead of deleting and reads back names in order` |
| Identity without ids | a section sent by its code keeps the id of the section that holds it, retired ones included; an item sent again in the same section keeps its entry; an item added to another section gets a new entry; a new section without code takes one derived from its name, the first free against every code of the menu | `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `keeps the id of a section sent without its id when its code is on the menu, retired ones included`; `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `keeps the id of an entry sent without its id when its item is already in that section, retired ones included`; `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `gives a new id to an item added to another section even when another section shows it`; `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `derives the code of a new section from its name, skipping every code of the menu, and keeps the stored code of a kept section`; `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `takes a section sent by its code alone for the section that holds the code, so its entries keep their ids` |
| Order and selections | top sections, subsections under their parent and entries by position; selected variants in the order sent, none for `all` | `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `orders top sections, their subsections and their entries by position, under their parent`; `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `plans the selected variants of an entry in the order sent, and none for all variants`; `src/modules/catalog/application/menus/menu-view.spec.ts` › `orders the top sections, their subsections and the entries of each by position, leaving retired ones out` |
| Send the structure the menu already has | `200`, the same body and tag, no write, no record, the catalog version unchanged, the plan never asked; a reorder, a rename, another variant order or a move is a change | `test/e2e/catalog-menus.e2e-spec.ts` › `answers the same structure again without a write, a record or a catalog version change`; `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `answers the stored menu when nothing changes, writing and recording nothing and never asking the plan`; `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `finds the stored structure the same when every live section and entry comes back unchanged`; `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `sees %s as a change` |
| Rename a section and change nothing else | one `catalog.menu.structure_set` record whose `before` and `after` differ and name the section as it was and as it is: the summary and the change check come from one key | `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `records a rename of a section alone with different before and after summaries that both name it`; `test/e2e/catalog-audit-renames.e2e-spec.ts` › `records the old and the new name of a menu section renamed and nothing else` |
| A stale `If-Match` on the structure or the name; the current one | `412 · foundation.precondition_failed` before anything is judged or written; `200` | `test/e2e/catalog-menus.e2e-spec.ts` › `refuses a stale If-Match with 412 and writes with the current one`; `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `refuses a stale If-Match before anything is judged or written, and accepts the current tag` |
| Activate an empty menu, one with nothing available, an available one; empty an active menu; archive; touch an archived one | `422 · catalog.menu_empty`; `422 · catalog.menu_nothing_available`; `200 active` with one `catalog.menu.activated` record; `422 · catalog.menu_empty`; `200 archived`, readable; `422 · catalog.menu_archived` for its structure, `422 · catalog.transition_invalid` for its activation | `test/e2e/catalog-menus.e2e-spec.ts` › `refuses to activate an empty menu or one with nothing available, activates it once an item sells, and archives it`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `activates a draft menu with an available entry and records the status before and after`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `refuses to activate an empty menu, a menu with nothing available or an archived menu, writing nothing`; `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `refuses to leave an active menu without entries`; `src/modules/catalog/domain/menus/menu-lifecycle.spec.ts` › `%s from %s is %s`; `src/modules/catalog/domain/menus/menu-lifecycle.spec.ts` › `blocks an empty menu, then a menu where nothing can be sold now, and lets the rest through` |
| Archive on a lapsed plan; archive again; without the permission | `200`, never asks the plan; `200` unchanged, no record; `403 · catalog.action_forbidden` | `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `archives without the plan, answers an archived menu unchanged, and needs the permission`; `src/modules/catalog/domain/menus/menu-lifecycle.spec.ts` › `gates the activation and the restore by the plan, never the unpublishing or the archival` |
| Unpublish an active menu, empty the draft, archive, `unpublish` an archived one, restore it, restore again, activate (API v7 PR 3) | `200` with exactly `{ menuId, status: draft }` and the tag the read answers, one `catalog.menu.unpublished`; `200`; `200`; `422 · catalog.transition_invalid`; `200 draft` with its structure, one `catalog.menu.restored`; `200`, no record; `200 active` | `test/e2e/catalog-menus.e2e-spec.ts` › `unpublishes an active menu to draft, restores an archived one to draft with its structure, answers lean with the tag the read answers and records each real change once`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `unpublishes an active menu back to draft without the plan and records it`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `restores an archived menu to draft after the plan, whatever it holds, and records it`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `refuses a transition the status does not allow, naming the menu, its status and the transitions it allows`; `src/modules/catalog/domain/menus/menu-lifecycle.spec.ts` › `names the transitions each status allows, in the order of the verbs`; `src/modules/catalog/presentation/dtos/menus/menu-transition.schemas.spec.ts` › `refuses %j` |
| Statement budget of every lifecycle route (API v7 PR 3) | 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, 8 for a repeat | `test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts` › `takes a category transition in 12 statements, 13 for a restore that judges its place under its parent again, and 10 for a repeat that records nothing`; `test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts` › `takes a modifier group transition in 14 statements, 13 for a reactivation or a restore that retire no offer default, and 11 for a repeat that records nothing`; `test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts` › `takes a modifier option transition in 14 statements, 13 for a reactivation or a restore that retire no offer default, and 11 for a repeat that records nothing`; `test/e2e/catalog-lifecycle-statement-budget.e2e-spec.ts` › `takes every menu transition in 10 statements, the activation that counts what the menu shows included, and 8 for a repeat that records nothing` |
| Every audit action the catalog records is seeded; each transition has its own action, a variant restore included | the seed of `0380_audit.sql` holds them all; no two transitions share an action | `src/modules/catalog/application/catalog-audit-vocabulary.spec.ts` › `seeds every action the catalog records, so no write is refused for an unknown action`; `src/modules/catalog/application/catalog-audit-vocabulary.spec.ts` › `records each transition under its own action, a variant restore included` |
| A new entry whose item is archived or of another catalog; a newly selected archived variant; one already on the section archived since | `422 · catalog.menu_item_unavailable` naming the field; the kept one stays | `test/e2e/catalog-menus.e2e-spec.ts` › `refuses an archived item as a new entry naming the field and shows a deactivated one as unavailable`; `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `refuses a new item that is archived or of another catalog, but keeps one already on that section`; `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `refuses a newly selected archived variant even on an entry already on the menu`; `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `accepts live items of the catalog in a new subsection` |
| A deactivated item; a suspended section; an inactive selected variant next to an active one | `isAvailable: false` and `availableEntryCount` without it; every entry under a suspended section, its subsections included, unavailable; available while one offered variant is active | `test/e2e/catalog-menus.e2e-spec.ts` › `refuses an archived item as a new entry naming the field and shows a deactivated one as unavailable`; `src/modules/catalog/application/menus/menu-view.spec.ts` › `counts the shown entries and calls one under a suspended section unavailable`; `src/modules/catalog/application/menus/menu-view.spec.ts` › `calls every entry under a suspended top section unavailable, its subsections included`; `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `shows a selected inactive variant as offered and the entry available while another variant is active`; `src/modules/catalog/domain/menus/menu-lifecycle.spec.ts` › `calls an entry available when it, its sections and its item are active and one of its variants is`; `src/modules/catalog/domain/menus/menu-lifecycle.spec.ts` › `calls it unavailable when %s` |
| An unknown section, entry, item or variant; an entry id with another item or section; a variant of another item; a code another section holds or held | `404 · catalog.not_found`, `422 · catalog.menu_entry_mismatch`, `422 · catalog.menu_variant_mismatch`, `409 · catalog.code_taken`, each with `meta.errors[]` naming the field and how to fix it; the first kind by precedence wins | `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `points at an unknown section or entry with not_found`; `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `refuses an entry id sent with another item or under another section`; `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `refuses an unknown item or variant and a variant of another item, naming each field`; `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `refuses a code another section of the menu holds, retired sections included`; `src/modules/catalog/presentation/failures/menu-refusal-failure.spec.ts` › `answers %s with %i and %s, pointing meta.errors at the field with a message that says how to fix it`; `src/modules/catalog/presentation/failures/menu-refusal-failure.spec.ts` › `answers the kind that comes first when several are refused, listing only its fields`; `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `refuses an archived item and a variant of another item with typed refusals naming the field, writing nothing` |
| Two sections that resolve to the same stored section: one by `sectionId`, another without it but with that section's code | `400 · foundation.validation_failed` with `meta.errors[]` at the alias (`sections[1].code`, or `.sectionId`), before any lookup; never reaches the upserts | `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `refuses two sections that resolve to the same stored section, one by its id and another by its code, pointing at the alias`; `test/e2e/catalog-menus.e2e-spec.ts` › `answers 404 for another organization, 403 to a member without the permission and 400 with pointers for broken rules` |
| An otherwise unchanged structure with an unknown entry id | refused before the tree is compared or any item is read | `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `judges the ids sent back before comparing, so an unchanged structure with an unknown entry id is refused` |
| Another organization; a cashier; a foreign item; selected without variants; a third level | `404`; `403 · catalog.action_forbidden` on the structure, the create and the read; `404` at `sections[0].entries[0].itemId`; `400` at `sections[0].entries[0].variantIds`; `400` at `sections[0].subsections[0].subsections` | `test/e2e/catalog-menus.e2e-spec.ts` › `answers 404 for another organization, 403 to a member without the permission and 400 with pointers for broken rules` |
| Rules that need no lookup | `400 · foundation.validation_failed` with the field: selected without variants, all with a list, a variant twice, an item twice in a section, a section, code or entry twice in the tree, a third level, more than 50 sections or 1,000 entries in all or 200 in a section, an unknown variant mode or field; an empty list is accepted; a section or variant sent twice reaching the use case is refused before any lookup | `src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts` › `points at selected variants without any, all variants with a list and a variant sent twice`; `src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts` › `points at an item twice in a section and a section, code or entry sent twice anywhere in the tree`; `src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts` › `refuses a third level, too many sections or entries, an unknown variant mode and unknown fields such as a price`; `src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts` › `accepts an empty list, which empties the menu`; `src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts` › `accepts the documented example and fills the defaults of a section and an entry`; `src/modules/catalog/domain/menus/menu-judgement.spec.ts` › `refuses a section, code, entry, item per section or variant per entry sent twice, before anything else` |
| A lapsed plan | a structure with sections, a create and an activation `403 · platform.capability_not_entitled`; emptying a draft menu and archiving `200` | `test/e2e/catalog-menus.e2e-spec.ts` › `refuses every change but emptying or archiving the menu on a lapsed plan`; `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `empties a draft menu on a lapsed plan and refuses adding a section`; `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `needs no plan only to empty the menu` |
| An unknown or archived menu | `404 · catalog.not_found`; `422 · catalog.menu_archived` | `src/modules/catalog/application/menus/set-menu-structure.use-case.spec.ts` › `answers not found for an unknown menu and refuses an archived one` |
| Read and list | any menu, archived ones included, after `catalog.items.view`; the list with counts by name; nothing locked or recorded; `403` before reading | `src/modules/catalog/application/menus/read-menus.use-case.spec.ts` › `reads any menu, archived ones included, and lists the menus after the view permission, without locking or recording anything`; `src/modules/catalog/application/menus/read-menus.use-case.spec.ts` › `answers not found for an unknown menu and forbidden without the permission, before reading`; `src/modules/catalog/presentation/dtos/menus/menu-response.spec.ts` › `documents examples that the schemas accept` |
| Create, list, replay, rename, taken codes | `201` draft (a code derived from its name when none is sent, `-2` on a repeat); the list by name with `sectionCount` and `entryCount`; the same key and body replays `201` with `Idempotent-Replayed: true`; a code taken ignoring case `409 · catalog.code_taken` on create and rename; a rename changes the tag and records only what changed | `test/e2e/catalog-menus.e2e-spec.ts` › `lists the menus with their counts, renames one, replays a create and refuses a taken code`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `creates a draft menu after the plan, records its code and name and answers it empty`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `derives the code from the name when none is sent, skipping the codes the catalog already holds`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `renames a menu writing and recording only what changed`; `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `answers an unchanged name without a write or a record, and refuses an archived menu`; `src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts` › `creates with a name and an optional code, and refuses a status or an empty name`; `src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts` › `edits a name or a code, at least one, and nothing else` |
| The 101st menu, archived ones counted | `422 · catalog.menu_limit_reached`, nothing written | `src/modules/catalog/application/menus/menu-commands.use-case.spec.ts` › `refuses a menu beyond the limit, archived ones included, and a lapsed plan` |
| Statement budget | `PUT .../structure` 18 statements for one or three sections (session, membership, `BEGIN`, tenant context, permission, version lock, menu lock, capability, candidates, retire sections, upsert sections, retire entries, upsert entries, delete selections, upsert selections, re-read, audit, `COMMIT`), 8 for an unchanged tree, `GET` 7, activation 10 | `test/e2e/statement-budget.e2e-spec.ts` › `sets, reads and activates a menu in the measured statements, whatever the number of sections and entries` |
| Selected variants | written in order; switching the entry to `all` deletes them and lists every variant that is not archived | `src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts` › `writes selected variants in order, drops them when the entry offers all variants again, and lists every active variant then` |
| Suspend and resume a section or an entry through `PUT .../structure` | `suspended: true` writes it `inactive` and answers `suspended: true`; the entries of a suspended section and a suspended entry leave `availableEntryCount`; sending them without the flag resumes them with the same ids; a retired row comes back as sent | `test/e2e/catalog-menu-suspension.e2e-spec.ts` › `derives the codes of a menu and its sections from their names, writes suspensions, counts them out of availability and resumes them, sent by their derived codes, with the same ids`; `src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts` › `revives a retired section or entry it writes again, and writes the status the plan sends: a suspended entry resumes, a section is suspended`; `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `writes a suspended section or entry as inactive and sees the suspension as a change` |
| The locking read | answers what the plain read answers; an unknown menu answers nothing; list and count agree | `src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts` › `locks the menu with its structure in the same statement and answers what the read answers; an unknown menu answers nothing` |
| Runtime privileges, version triggers and audit vocabulary | `SELECT`, `INSERT`, `UPDATE`, never `DELETE` nor `TRUNCATE`, on menus, sections and entries; `DELETE` on the selected variants only; `EXECUTE` on the entry check; one bump per statement kind on insert, update and delete of the four tables; a no-op upsert and an `UPDATE` that changes nothing do not bump; five audit actions and the `catalog.menu` resource kind | `database/tests/contracts/catalog-menus-contracts.ts` › `one INSERT of two entries moves the catalog version once`; `database/tests/contracts/catalog-menus-contracts.ts` › `the same upsert again, a no-op, leaves the catalog version as it was`; `database/tests/contracts/catalog-menus-contracts.ts` › `an UPDATE that changes no entry leaves the catalog version as it was`; `database/tests/contracts/catalog-menus-contracts.ts` › `archiving a menu is an UPDATE that moves the version once`; `database/tests/contracts/catalog-menus-contracts.ts` › `the runtime may delete a selected variant of an entry, which nothing references, but never truncate them`; `database/tests/contracts/catalog-menus-contracts.ts` › `the runtime cannot delete a menu entry, not even its own`; `database/tests/contracts/catalog-menus-contracts.ts` › `the runtime cannot delete a menu section, not even its own`; `database/tests/contracts/catalog-menus-contracts.ts` › `the runtime cannot delete a menu, not even its own`; `database/tests/contracts/catalog-menus-contracts.ts` › `the five menu commands have their audit actions, configuration, medium, before and after` |
| SQL checks | a section and an entry can be retired; another status is refused; a creation hash without its key is refused and a key is used once; an entry in `selected` mode without a variant is refused at commit | `database/tests/contracts/catalog-menus-contracts.ts` › `a section and an entry can be retired`; `database/tests/contracts/catalog-menus-contracts.ts` › `a section status other than active, inactive or retired is refused`; `database/tests/contracts/catalog-menus-contracts.ts` › `an entry status other than active, inactive or retired is refused`; `database/tests/contracts/catalog-menus-contracts.ts` › `a creation request hash without its key is refused`; `database/tests/contracts/catalog-menus-contracts.ts` › `a creation key is used once per organization`; `database/tests/contracts/catalog-menus-contracts.ts` › `an entry in selected mode without any variant is refused when the transaction commits` |
| Another tenant | sees and updates none of the three tables; a menu naming a foreign organization is refused (`42501`); the repository finds no item, variant or menu of another organization | `database/tests/contracts/catalog-menus-contracts.ts` › `a menu naming a foreign organization is refused`; `src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts` › `answers the items and variants it finds, wherever they belong, and reads nothing of another organization` |
| Extra application fields | never reach the answer: an entry and a variant carry exactly their documented keys | `src/modules/catalog/presentation/dtos/menus/menu-response.spec.ts` › `answers exactly the documented fields of an entry and a variant, whatever else the application carries` |
| Write failures | the section code index, the entry item foreign keys and the selected-variant foreign keys map to typed refusals keeping the cause; the menu code index to `catalog.code_taken`; any other failure passes through | `src/modules/catalog/infrastructure/menus/menu-write-failure.spec.ts` › `maps %s on %s to a %s refusal that keeps the database error as its cause`; `src/modules/catalog/infrastructure/menus/menu-write-failure.spec.ts` › `maps a taken menu code to the code refusal of the catalog`; `src/modules/catalog/infrastructure/menus/menu-write-failure.spec.ts` › `lets any other failure through unchanged, a violation of another constraint included`; `src/modules/catalog/infrastructure/menus/menu.repository.integration-spec.ts` › `maps a taken menu code and an item of another catalog to typed refusals that keep the cause`; `src/modules/catalog/presentation/failures/menu-refusal-failure.spec.ts` › `answers the %s refusal with 422 %s` |
| Audit summary | the live sections with their names and the entries in tree order; `null` for an empty menu | `src/modules/catalog/domain/menus/menu-structure.spec.ts` › `summarizes the live sections with their names and the entries, and answers null for an empty menu` |

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

`GET` and `PUT /catalog/outlets/{outletId}/menus` with `{ menuIds }`; the read needs `catalog.items.view` or `catalog.assortment.manage` at the outlet, the write `catalog.assortment.manage` at the outlet, gated by `catalog.items` unless the list is empty.

| Case | Expected answer | Proving test |
|---|---|---|
| An outlet nobody assigned menus to; two menus set in order with the tag read; revalidation | `200` `{ outletId, menus: [] }`, `private, no-cache`; the `PUT` answers `200` with the menus in the order sent and a new strong tag; `GET` answers the same body and tag; `If-None-Match` answers `304` with no body; positions 0 and 1 stored; one `catalog.outlet.menus_set` record with the ids after | `test/e2e/catalog-outlet-menus.e2e-spec.ts` › `reads an outlet that shows nothing, sets two menus in order with the tag it read, and revalidates with 304` |
| The same menus in the same order; a new order of the same menus | `200`, nothing written, no record, the catalog version unchanged; a new order is a change: written and recorded once | `test/e2e/catalog-outlet-menus.e2e-spec.ts` › `answers the same menus in the same order with nothing written, and writes a new order once`; `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `answers the same menus in the same order with nothing written or recorded`; `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `writes a new order of the same menus, reading no candidate` |
| The till of the outlet after its menus change | its tag moves (the catalog version moved); what it sells is the same: menus only organize | `test/e2e/catalog-outlet-menus.e2e-spec.ts` › `moves the till tag when the menus of the outlet change, and never changes what the till sells` |
| `If-Match` missing or `*`; a stale tag | `428 precondition.required`; `412 foundation.precondition_failed`, checked after the version lock and the read; nothing written | `test/e2e/catalog-outlet-menus.e2e-spec.ts` › `requires If-Match, refuses a stale one and writes nothing`; `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `refuses a stale If-Match after the version lock and accepts the tag the read answered` |
| A new menu unknown, of another organization or archived; eleven menus; a repeated menu | `404 catalog.not_found` at `menuIds[i]`; `422 catalog.menu_archived` at `menuIds[i]`; `400 foundation.validation_failed` at `menuIds` and at the repetition; an unknown menu wins over an archived one; nothing written | `test/e2e/catalog-outlet-menus.e2e-spec.ts` › `points at the menu it refuses: unknown, of another organization, archived, too many or repeated`; `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `refuses an unknown menu, or one of another organization, before an archived one`; `src/modules/catalog/presentation/failures/outlet-menus-refusal-failure.spec.ts` › `answers an unknown menu first when another one is archived, listing only the unknown ones` |
| A menu archived after it was assigned | stays listed with status `archived` and keeps its place when the list is sent again; unpublishing and archiving an assigned menu keep working | `test/e2e/catalog-outlet-menus.e2e-spec.ts` › `keeps showing a menu archived after it was assigned, with its status, and its menu transitions keep working`; `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `keeps an assigned menu that was archived since, and refuses one that starts being shown archived, pointing at it` |
| Order of the steps of a write | `catalog.assortment.manage` with the plan at the outlet, then the catalog version row, then the stored list, the new menus only, one delete and one insert, one record, the read of the answer | `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `authorizes catalog.assortment.manage with the plan at the outlet, locks the catalog version before reading, then writes the set and records one change` |
| An empty list | never gated by the plan and reads no menu; one record with the ids before and `null` after | `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `empties an outlet without the plan and without reading any menu`; `test/e2e/catalog-outlet-menus-access.e2e-spec.ts` › `gates every list but the empty one by the plan, so taking every menu off an outlet always works` |
| A cashier, and a manager of another outlet, at this outlet or an unknown one | the same plain `403 catalog.action_forbidden` on both routes; the write refusals are audited `permission_missing`; nothing written | `test/e2e/catalog-outlet-menus-access.e2e-spec.ts` › `answers a cashier, and a manager of another outlet, the same plain denial on both routes whether the outlet exists or not, audits the write refusals and writes nothing` |
| A manager assigned to the outlet | reads and sets its menus | `test/e2e/catalog-outlet-menus-access.e2e-spec.ts` › `lets a manager assigned to the outlet read and set its menus`; `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `lets a member who sets the menus read them without catalog.items.view` |
| The organization-wide owner at an unknown outlet or one of another organization | `404 organization.structure_not_found` on both routes, empty list or not | `test/e2e/catalog-outlet-menus-access.e2e-spec.ts` › `tells the organization-wide owner that an unknown outlet, or one of another organization, does not exist`; `src/modules/catalog/application/outlet-menus/outlet-menus.use-case.spec.ts` › `tells only an organization-wide holder that the outlet does not exist, and refuses a closed outlet with its own reason` |
| A list at an outlet that is not operating | `422 authorization.outlet_not_operational`, audited; the empty list still works | `test/e2e/catalog-outlet-menus-access.e2e-spec.ts` › `refuses a list at an outlet that is not operating, and still lets it be emptied` |
| Body rules | 0 to 10 ids in order; an eleventh at `menuIds`; each repetition at its position; a missing list, a malformed id and any other field refused | `src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts` › `refuses more menus than an outlet can show, pointing at the list`; `src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts` › `points one issue at each repeated menu`; `src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts` › `refuses a missing list, a malformed id and any other field` |
| Statements | `GET` 7, `304` 7; `PUT` 15 whatever the number of menus, 14 without a stored list or without a new menu, 13 to empty, 10 for the same list | `test/e2e/catalog-outlet-menus-statement-budget.e2e-spec.ts` › `reads the menus of an outlet in 7 statements and answers 304 in the same, whatever the number of menus`; `test/e2e/catalog-outlet-menus-statement-budget.e2e-spec.ts` › `sets the menus of an outlet in 15 statements whatever their number, 14 without a stored list to delete or without a new menu to judge, 13 to empty it and 10 for the same list, which records nothing` |
| Repository | an unknown outlet told apart; the order sent kept; one version move per statement; only the menus of the organization judged | `src/modules/catalog/infrastructure/outlet-menus/outlet-menu.repository.integration-spec.ts` › `reads an outlet that shows nothing, and tells an unknown outlet apart`; `src/modules/catalog/infrastructure/outlet-menus/outlet-menu.repository.integration-spec.ts` › `writes the menus in the order sent, replaces them as one set and moves the catalog version once per statement`; `src/modules/catalog/infrastructure/outlet-menus/outlet-menu.repository.integration-spec.ts` › `answers the status of the menus of the organization only, and another organization sees no assignment` |
| SQL table | one menu once per outlet, one menu per position, positions 0 to 9; no `UPDATE` for the runtime; one version move per statement; the organization of a row never changes | `database/tests/contracts/catalog-outlet-menus-contracts.ts` › `the same menu twice at one outlet is refused`; `database/tests/contracts/catalog-outlet-menus-contracts.ts` › `two menus at the same position of one outlet are refused`; `database/tests/contracts/catalog-outlet-menus-contracts.ts` › `one INSERT of two assignments moves the catalog version once`; `database/tests/contracts/catalog-outlet-menus-contracts.ts` › `an assignment never moves to another organization` |

## Outlet hidden items

Slice K16 (migration `0106`, baseline revision 116): the items of the assortment of its business unit that an outlet does not sell (`CATALOG_USE_CASES.md`, "K16 as built"). An outlet can only hide; it never sells what its unit does not list.

### C30 — Choose which items an outlet does not sell

`GET` and `PUT /catalog/outlets/{outletId}/hidden-items` with `{ itemIds }`; the read needs `catalog.items.view` or `catalog.assortment.manage` at the outlet, the write `catalog.assortment.manage` at the outlet, gated by `catalog.items` unless the set is empty.

| Case | Expected answer | Proving test |
|---|---|---|
| An outlet that hides nothing; two items set with the tag read; revalidation | `200` `{ outletId, items: [] }`, `private, no-cache`; the `PUT` answers `200` with the items by name, each `{ itemId, code, name, status }`, and a new strong tag; `GET` answers the same body and tag; `If-None-Match` answers `304` with no body; the ids stored and one `catalog.outlet.hidden_items_set` record with the sorted ids | `test/e2e/catalog-outlet-hidden-items.e2e-spec.ts` › `reads an outlet that hides nothing, sets two items with the tag it read, and revalidates with 304` |
| The same set in any order; a different set | `200`, nothing written, no record, the catalog version unchanged; a different set is written and recorded once | `test/e2e/catalog-outlet-hidden-items.e2e-spec.ts` › `answers the same set in any order with nothing written, and writes a different set once`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `answers the same set in any order with nothing written or recorded` |
| The till of the outlet after its set changes | the item leaves the till of that outlet, the till tag moves and the tag it sent before no longer answers `304`; an empty set sells it again | `test/e2e/catalog-outlet-hidden-items.e2e-spec.ts` › `takes the item out of the till of that outlet, moves the till tag, and the tag it had no longer answers 304` |
| `If-Match` missing or `*`; a stale tag | `428 precondition.required`; `412 foundation.precondition_failed`, checked after the version lock and the read; nothing written | `test/e2e/catalog-outlet-hidden-items.e2e-spec.ts` › `requires If-Match, refuses a stale one and writes nothing`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `refuses a stale If-Match after the version lock and accepts the tag the read answered` |
| Two writes that name the same tag | one `200` and one `412`, and the stored set is the winner's | `test/e2e/catalog-outlet-hidden-items.e2e-spec.ts` › `answers 200 to one of two writes that name the same tag and 412 to the other` |
| An item unknown or of another organization; 501 items; a repeated item | `404 catalog.not_found` at `itemIds[i]` with a message that says how to fix it; `400 foundation.validation_failed` at `itemIds` and at the repetition; nothing written | `test/e2e/catalog-outlet-hidden-items.e2e-spec.ts` › `points at the item it refuses: unknown, of another organization, too many or repeated`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `refuses an unknown item, or one of another organization, pointing at its position, and writes nothing`; `src/modules/catalog/presentation/failures/outlet-hidden-items-refusal-failure.spec.ts` › `answers an unknown item with 404 catalog.not_found, pointing meta.errors at every unknown position with a message that says how to fix it`; `src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts` › `points at the position of every requested item the organization does not have`; `src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts` › `judges only the items that start being hidden` |
| An item archived or deactivated after it was hidden; an archive racing a write | stays listed with its status until it is taken out; an item of any status can be hidden; the race ends in one consistent state (the write `200` or `412`, never half) | `test/e2e/catalog-outlet-hidden-items.e2e-spec.ts` › `keeps an item archived after it was hidden, with its status, and an archive racing a write leaves one consistent state`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `hides an item of any status, so an archived or deactivated one can be hidden too` |
| Order of the steps of a write | `catalog.assortment.manage` with the plan at the outlet, then the catalog version row, then the stored set, the new items only, one delete and one insert, one record, the read of the answer | `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `authorizes catalog.assortment.manage with the plan at the outlet, locks the catalog version before reading, then writes the set and records one change` |
| An empty set | never gated by the plan and reads no item; one record with the ids before and `null` after | `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `makes the outlet sell everything again without the plan and without reading any item`; `test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts` › `gates every set but the empty one by the plan, so making an outlet sell everything again always works` |
| A cashier, and a manager of another outlet, at this outlet or an unknown one | the same plain `403 catalog.action_forbidden` on both routes; the write refusals are audited `permission_missing`; nothing written | `test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts` › `answers a cashier, and a manager of another outlet, the same plain denial on both routes whether the outlet exists or not, audits the write refusals and writes nothing`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `records the refusal of a member without the permission and answers the plain 403`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `refuses a caller without either permission with the plain 403, without learning whether the outlet exists` |
| A manager assigned to the outlet | reads and sets its hidden items, and neither reads nor sets those of a sibling outlet (the sibling write is sent and answers the plain `403`, its stored set unchanged) | `test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts` › `lets a manager assigned to the outlet read and set its hidden items, and neither read nor set those of a sibling outlet`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `lets a member who sets the hidden items read them without catalog.items.view`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `decides catalog.items.view at the outlet, then reads the hidden items by name in one step` |
| The organization-wide owner at an unknown outlet or one of another organization | `404 organization.structure_not_found` on both routes, empty set or not; an admitted caller at an unknown outlet `404` | `test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts` › `tells the organization-wide owner that an unknown outlet, or one of another organization, does not exist`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `tells a holder refused at an unknown outlet that it does not exist only when it holds a permission organization-wide`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `answers 404 to an admitted caller when the outlet does not exist`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `tells only an organization-wide holder that the outlet does not exist, and refuses a closed outlet with its own reason` |
| A set at an outlet that is not operating | `422 authorization.outlet_not_operational`, audited; the empty set still works | `test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts` › `refuses a set at an outlet that is not operating, and still lets it be emptied` |
| Another organization's items and outlets | an item of another organization is `404` and nothing is hidden; the outlet of another organization is `404` for the reader | `test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts` › `never lets an organization hide, or read, what belongs to another one` |
| Body rules | 0 to 500 ids; a 501st at `itemIds`; each repetition at its position; a missing list, a malformed id and any other field refused; the answer carries exactly `itemId`, `code`, `name` and `status` | `src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts` › `takes no item, one, or up to the limit, in the order sent`; `src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts` › `refuses more items than an outlet can hide, pointing at the list`; `src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts` › `points one issue at each repeated item`; `src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts` › `refuses a missing list, a malformed id and any other field`; `src/modules/catalog/presentation/dtos/outlet-hidden-items/outlet-hidden-items.schemas.spec.ts` › `answers the outlet and, for each hidden item, its id, code, name and status only` |
| What the set means | compared as a set, not as an order; only the items that start being hidden are judged | `src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts` › `compares the sets and not the order, because hiding has no order`; `src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts` › `tells a different set from the stored one`; `src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts` › `keeps the requested items the outlet does not hide yet, in the requested order` |
| Till list, search, scan and sellable facts of the outlet that hides an item; its sibling outlet of the same unit | the item is absent from the list, the search and the scan and is not listed in the facts of that outlet; the sibling lists, finds, scans and sells it; no outlet is the unit-level answer; shown again, it is sold again | `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts` › `leaves the till list, the search, the scan and the sellable facts of that outlet only, and the sibling outlet of the same unit keeps selling it` |
| An item the unit does not list | never sold at any outlet, hidden or not: an outlet can only hide | `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts` › `never makes an outlet sell what its business unit does not list` |
| An outlet that moves to another business unit that lists the item too | the move is proven first (the outlet operates the new unit, the old unit is refused for it with `outlet_not_operated_by_unit`); the hidden item stays hidden there, a sibling outlet of the new unit sells it, showing it again sells it | `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts` › `stays hidden after the outlet moves to another unit that lists the item too, while a sibling outlet of the new unit sells it and the move itself is proven to have taken effect`; `test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts` › `keeps the hidden item hidden after the outlet moves to another business unit that lists it too, while a sibling outlet of the new unit sells it and the move is proven to have taken effect` |
| A menu entry of a hidden item | leaves the menus of that outlet only; the sibling keeps it | `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts` › `leaves the entries of the menus it appears in at that outlet only`; `test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts` › `takes the entry of a hidden item out of the menus of that outlet only` |
| A combo whose only component the outlet hides | incomplete at that outlet, in every read and in the admission (`not_listed` at the choice), complete at the sibling and again when shown | `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts` › `makes a combo whose only component it hides incomplete at that outlet and sellable at the sibling`; `test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts` › `makes a combo whose only component the outlet hides incomplete there, in every read and in the admission, and complete at the sibling` |
| An option that targets an item, in a group an item that is itself sold offers, while the target is listed and shown | available at every outlet of the unit: in the till list and its group options, the search, `resolve` and `admit`, with an outlet and without one | `test/e2e/catalog-option-target-listing.e2e-spec.ts` › `offers the option at every outlet, in every read and in the admission, while its target is listed and shown` |
| The unit delists the target from an assortment that lists everything; it lists it again | the option is unavailable at every outlet of the unit and at the unit level (`resolve`, `admit` without `outletId`): an optional group leaves the offers and the item stays on sale, a required group with one option leaves the item out of the lists and the scan (`required_modifier_unsatisfiable`), a required group with a second option keeps the item with that option alone; selecting the delisted option is refused `option_unavailable`; listing it again restores all of it | `test/e2e/catalog-option-target-listing.e2e-spec.ts` › `takes the option away from every outlet of a unit that delists its target from an assortment that lists everything, and offers it again when the target is listed` |
| The unit has a selected assortment that lists the offering items but not the target | the same answers as a delisted target | `test/e2e/catalog-option-target-listing.e2e-spec.ts` › `takes the option away from a unit whose selected assortment lists the offering items but not the target` |
| An outlet hides the target; its sibling outlet of the same unit does not; the outlet shows it again | the option is unavailable at the outlet that hides it, in every read and in the admission, and available at the sibling outlet and at the unit level, where only the delisting applies; showing it restores the option | `test/e2e/catalog-option-target-listing.e2e-spec.ts` › `takes the option away from the outlet that hides its target only, keeps it at the sibling outlet and at the unit level, and offers it again when the target is shown` |
| The target is a combo that cannot be composed | the option is unavailable at every outlet, judged as a combo component is (`bundleComposable`) | `test/e2e/catalog-option-target-listing.e2e-spec.ts` › `takes the option away from every outlet when its target is a combo that cannot be composed` |
| A combo whose only component needs an option whose target the unit delists | incomplete in every read (`bundle_group_unsatisfiable`) and complete again when the target is listed: the options of a combo component are judged by the same scope | `test/e2e/catalog-option-target-listing.e2e-spec.ts` › `leaves out a combo whose only component cannot be configured any more because the target of its required option is delisted, in every read, and brings it back when the target is listed` |
| The target is a combo that could be composed | the option is unavailable too: an option sells a plain item | `test/e2e/catalog-option-target-listing.e2e-spec.ts` › `takes the option away when its target is a combo that could be composed, because an option sells a plain item` |
| The statements judge the targets where they read them | the scan and the filter of the search judge the target of each option they look at (`inline`); the list builds `option_targets` for the organization, `resolve` for the items it serves and their combo components, the page of the search `page_targets` for the items of the page; the writes keep the structural `activeOption` | `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `asks a set of targets the statement judged once, with a keyed lookup and no probe per option, when the scope carries one`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `judges the target of each option where it is read, as the unit and outlet of the reader would sell it, when the scope is inline: an active variant of an active item that is listed, not hidden and opens no group`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `judges each distinct active target once per statement with the one target rule, listing and hiding included`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `judges every active target of the organization when it is not told which items it serves, as the list does`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `judges only the targets of the options offered by the items it is told about, and by the components of their combos when asked: the page of the search and resolve`; `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `is built by the list and resolve for their targets, by the search for the items of its page only, and not at all by the scan, which judges its options where it reads them` |
| A group whose option sells a variant of a combo; the same group on a plain variant | `422 · catalog.option_target_not_plain`, nothing written; `201` on the plain variant | `test/e2e/catalog-option-target-plain.e2e-spec.ts` › `refuses a group whose option sells a variant of a combo with 422, writing nothing, and creates the same group on a plain variant` |
| A new option on a variant of a combo in a set; a plain one | `422 · catalog.option_target_not_plain` at `options[i].variantId`, nothing written; `200` on the plain variant | `test/e2e/catalog-option-target-plain.e2e-spec.ts` › `refuses a new option on a variant of a combo in a set with 422 at the option, writing nothing, and accepts a plain one` |
| The domain and the use cases judge a combo variant after unknown and inactive ones | the target is refused as not plain once it is known and active, in the create and in the set, before any write | `src/modules/catalog/domain/modifiers/modifier-option-set.spec.ts` › `refuses a new option whose variant belongs to a combo as not plain, once the variant is known and active, and keeps judging the others`; `src/modules/catalog/application/modifiers/set-modifier-group.use-case.spec.ts` › `points at a new option whose variant belongs to a combo as not plain after share-locking it, writing nothing`; `src/modules/catalog/application/modifiers/create-modifier-group.use-case.spec.ts` › `refuses a group whose option sells a variant of a combo as not plain, after share-locking it and before any write` |
| The variants are locked with their items and read for combos | the variant and its item are share-locked in id order and the same statement reports whether the item opens a group, with the live-group fragment of the read; a combo variant is reported not plain | `src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts` › `says in the statement that locks the variants whether each belongs to a combo, with the live-group fragment the till uses, so a write and a read agree on what a plain item is`; `src/modules/catalog/infrastructure/modifiers/modifier-group.queries.spec.ts` › `locks the group for update and shares the variants and the offered groups in id order`; `src/modules/catalog/infrastructure/modifiers/modifier-group.repository.integration-spec.ts` › `locks the variants and says which of them belong to a combo, judging a combo by its live groups and a plain item by having none` |
| The back-office item read (C3) | unchanged: it shows the stored status of each option and its target, which has no unit to be judged at | `src/modules/catalog/infrastructure/reads/sellable-rule.queries.spec.ts` › `needs an active option whose target variant, when it has one, is active and belongs to an active item, the structural rule of the writes, which have no unit to judge listing for` |
| The list, the search, the scan, `resolve` and `admit` on one hidden item | all four agree: not listed, not found, not scanned, `not_listed` at `lines[0]`; the sibling outlet sells it in all four | `test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts` › `refuses an item with the not-listed vocabulary at the outlet that hides it everywhere at once, while the sibling outlet of the same unit sells it, and sells it again when it is shown` |
| One SQL fragment | the probe is one keyed lookup by organization, outlet and item, composed once and found in the list, the search, the scan, the facts and every combo level; without an outlet no statement mentions the table | `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `is one keyed probe of the hidden items by organization, outlet and item, asked for one row`; `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `adds the hidden probe of the outlet to the assortment of the unit when the scope has an outlet`; `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `is the assortment of the unit alone when the scope has no outlet`; `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `judges the hidden set through the one shared listing fragment`; `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `asks the hidden set of the outlet about the component of every combo level, so a hidden component is unavailable exactly like an unlisted one`; `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `is the very text bundleComposable counts the choices of a combo with`; `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `keep the hidden items of the outlet the statement already carries, with no parameter other than the instant of the read`; `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `keep the scan and the sellable facts on the same fragment`; `src/modules/catalog/infrastructure/reads/sellable-rule-outlet.queries.spec.ts` › `never write the hidden table` |
| An outlet that does not exist, one of another organization, one another unit operates, no outlet at all (resolve and admit) | `outlet_not_operated_by_unit` for the first three, in the verdict and in the admission, judged in the same statement as the rest and right after `business_unit_unavailable`; no outlet keeps judging the unit alone, a documented mode of the contract | `test/e2e/catalog-outlet-hidden-items-parity.e2e-spec.ts` › `refuses, at resolve and at admit, an outlet that does not exist, one of another organization and one another unit operates, and keeps judging the unit alone without an outlet`; `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.till.integration-spec.ts` › `fails closed on an outlet the business unit does not operate: one that does not exist, one of another organization and one another unit operates today, while no outlet at all keeps judging the unit alone`; `src/modules/catalog/domain/sellable/sellability.spec.ts` › `refuses an outlet the business unit does not operate right after the unit and before any fact about the item, naming the outlet` |
| `variant_is_listed_at_outlet` with an outlet that does not exist, one of another organization, one another unit operates, NULL, and a NULL unit | `false` in every case (fails closed); `true` for an outlet the unit operates, so the refusals come from the outlet alone; the unit-level function keeps answering without any outlet | `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item the unit lists is not listed at an outlet that does not exist: the function fails closed`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item the unit lists is not listed at an outlet of another organization: the function fails closed`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item the unit lists is not listed at an outlet that another business unit operates today: the function fails closed`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item the unit lists is not listed at no outlet at all (NULL): the function fails closed`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item the unit lists is not listed at no business unit (NULL): the function fails closed`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `the same item is listed at an outlet the unit operates, so the refusals above come from the outlet alone`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `the unit-level function keeps answering without any outlet` |
| An item sent twice | the schema refuses it at the position of the repeat (`400`, `itemIds[1]`) and the stored set, the catalog version and the audit stay as they were; the use case, reached by other callers, treats the list as a set: `[A, A]` over `[A]` is a no-op and `[B, A, B, A]` writes and records `A,B` once | `test/e2e/catalog-outlet-hidden-items.e2e-spec.ts` › `refuses an item sent twice at the position of the repeat and leaves the stored set, the catalog version and the audit as they were`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `treats a set as a set: an item sent twice is the item once, so the same set sent with a repeat is still a no-op that writes and records nothing`; `src/modules/catalog/application/outlet-hidden-items/outlet-hidden-items.use-case.spec.ts` › `writes and records a repeated item once, in the order first sent`; `src/modules/catalog/domain/outlet-hidden-items/outlet-hidden-item-set.spec.ts` › `keeps each item once, in the order it was first sent` |
| An outlet id that is not a UUID, sent by a member who holds no permission | `400 foundation.validation_failed` on both routes, before any permission is decided; the same member gets `403 catalog.action_forbidden` for a well-formed id | `test/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts` › `answers 400 to an outlet id that is not a UUID on both routes before any permission is decided, to a member who holds none, and 403 to the same member for a well-formed id` |
| The generated OpenAPI document | the read and its `304` declare the `ETag` header (the only answers a conditional read documents; the semantic check refuses it on a write) and the text of the write says the new tag comes in the `ETag` response header | `src/modules/catalog/presentation/controllers/outlet-hidden-items/outlet-hidden-items-route-documentation.spec.ts` › `declares the ETag header on the read and its 304, the only answers a conditional read documents`; `src/modules/catalog/presentation/controllers/outlet-hidden-items/outlet-hidden-items-route-documentation.spec.ts` › `tells in the text of the write, as every write of the catalog does, that the new tag comes in the ETag header of the answer` |
| An operated outlet, a closed one, one without a business unit, an unknown id, an outlet of another organization | the presence check, the menus read and the hidden items read give the same answer: known for the first three, unknown for the last two (one shared predicate, `outletIsKnown`) | `src/modules/catalog/infrastructure/outlets/outlet-presence.integration-spec.ts` › `is the answer of the presence check, of the hidden items read and of the menus read for an operated outlet, a closed one, one without a business unit, an unknown id and an outlet of another organization` |
| Statements | `GET` 7, `304` 7; `PUT` 15 whatever the number of items, 14 without a stored set or without a new item, 13 to empty, 10 for the same set in any order | `test/e2e/catalog-outlet-hidden-items-statement-budget.e2e-spec.ts` › `reads the hidden items of an outlet in the same statements as its menus and answers 304 in the same, whatever the number of items`; `test/e2e/catalog-outlet-hidden-items-statement-budget.e2e-spec.ts` › `sets the hidden items in 15 statements whatever their number, 14 without a stored set to delete or without a new item to judge, 13 to empty it and 10 for the same set, which records nothing` |
| A write of the menus or the hidden items, a replacement or a clearing, by a caller anchored only at the old unit, after the outlet moved | the plain `403 catalog.action_forbidden`; nothing written, no success record, no catalog version move | `test/e2e/catalog-outlet-write-permission-race.e2e-spec.ts` › `refuses $label by a caller anchored only at the old unit once the outlet moved before the first permission decision, and writes nothing` |
| The reassignment commits between the first permission decision and the lock | the second decision refuses with the plain `403`; nothing written | `test/e2e/catalog-outlet-write-permission-race.e2e-spec.ts` › `refuses $label when the reassignment commits between the first permission decision and the lock, and writes nothing` |
| A reassignment starts between the lock and the second decision, or after it | it waits for the write to commit; the write is answered `200` for the unit it started with and the outlet moves afterwards | `test/e2e/catalog-outlet-write-permission-race.e2e-spec.ts` › `holds back a reassignment that starts $label between the lock and the re-check until the write commits`; `test/e2e/catalog-outlet-write-permission-race.e2e-spec.ts` › `holds back a reassignment that starts $label after the re-check until the write commits` |
| A write, a queued reassignment and a second write behind it | all finish, no deadlock; the first `200`, the second the plain `403` | `test/e2e/catalog-outlet-write-lock-order.e2e-spec.ts` › `lets a write, a queued reassignment and a second write behind it all finish without a deadlock, the second one refused` |
| A write that waits behind a reassignment longer than the lock timeout | `503 service.unavailable` as Problem Details, nothing written | `test/e2e/catalog-outlet-write-lock-order.e2e-spec.ts` › `answers the taught 503 and writes nothing when $label waits behind a reassignment longer than the lock timeout` |
| A caller refused after the lock | exactly one denial audit record, in the transaction of the refusal, and nothing else written | `test/e2e/catalog-outlet-write-lock-order.e2e-spec.ts` › `records exactly one denial, in the transaction of the refusal, for a caller refused after the lock` |
| The `GET` of the menus and of the hidden items | one read-only snapshot, never a plain transaction | `test/e2e/catalog-outlet-write-lock-order.e2e-spec.ts` › `reads the menus and the hidden items of an outlet in one read-only snapshot` |
| Repository | an unknown outlet told apart; one version move per statement; only the items of the organization judged; another organization sees no hidden item | `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.repository.integration-spec.ts` › `reads an outlet that hides nothing, and tells an unknown outlet apart`; `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.repository.integration-spec.ts` › `replaces the set as one delete and one insert, reads it by name and moves the catalog version once per statement`; `src/modules/catalog/infrastructure/outlet-hidden-items/outlet-hidden-items.repository.integration-spec.ts` › `answers the items of the organization only, and another organization sees no hidden item of this outlet` |
| SQL table and function | one row per outlet and item; the primary key leads with the organization and the outlet; no `UPDATE` for the runtime; one version move per statement; the organization of a row never changes; foreign keys keep items and outlets of one organization; `variant_is_listed_at_outlet` hides every variant of a hidden item at that outlet only and never adds an item the unit does not list | `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `the same item twice at one outlet is refused`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `the runtime cannot update a hidden item: the set is deleted and inserted again`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `one INSERT of two hidden items moves the catalog version once`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `the primary key leads with the organization and the outlet, so the till probe is a keyed lookup`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `the hidden_items_set audit action is seeded`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item the unit does not list is not listed at any outlet`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item the unit lists and the outlet does not hide is listed`; `database/tests/contracts/catalog-outlet-hidden-items-contracts.ts` › `an item the outlet does not hide stays listed at it` |

***

## Menu time windows

Slice S1 (migration `0107`, baseline revision 117): the hours in which an outlet shows each of its menus (`CATALOG_USE_CASES.md`, C31; `CATALOG_OPTIONS_V2.md`, "S1 as built"). The windows ride on `PUT /catalog/outlets/{outletId}/menus` and the till read keeps the menus that are open at the instant of the read.

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

| Case | Expected answer | Proving test |
|---|---|---|
| Windows set with the tag read, read back, revalidated | `200` with each menu carrying `windows` in canonical order (by hour, then days ascending), a window without days opens every weekday, the tag of the read equals the one written and `If-None-Match` answers `304`; the rows stored with `spans_midnight` for a window that crosses midnight | `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `sets the windows of each menu, reads them back in canonical order with the tag it wrote, and revalidates with 304`; `src/modules/catalog/infrastructure/outlet-menus/outlet-menu.repository.integration-spec.ts` › `writes the windows of each menu, reads them in canonical order and replaces them with the menus as one set` |
| Only a window changes | the tag moves and one `catalog.outlet.menus_set` record carries the windows before and after as their own change, and the menus only when they moved | `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `moves the tag when only a window changes, and records the windows before and after as their own change`; `src/modules/catalog/application/outlet-menus/outlet-menu-windows.use-case.spec.ts` › `records the windows before and after as their own change, and the menus only when they moved`; `src/modules/catalog/application/outlet-menus/outlet-menu-windows.use-case.spec.ts` › `records both changes when the menus and their windows move together` |
| The same windows in any order, the days in any order | `200`, nothing written, no record, the catalog version unchanged | `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `answers the same windows, in any order and with the days in any order, with nothing written or recorded`; `src/modules/catalog/application/outlet-menus/outlet-menu-windows.use-case.spec.ts` › `answers the same windows, sent in another order, with nothing written or recorded` |
| A body without `windows`; a menu taken off the outlet | every menu is always shown and the stored windows are replaced; the windows of a menu that leaves do not outlive it | `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `treats a body without windows, the form that predates them, as every menu always shown`; `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `keeps the windows of a menu that is taken off the outlet from outliving it`; `src/modules/catalog/application/outlet-menus/outlet-menu-windows.use-case.spec.ts` › `removes the windows of a menu sent without them, replacing the stored ones` |
| `If-Match` missing; a stale tag | `428 precondition.required`; `412 foundation.precondition_failed`; nothing written | `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `requires If-Match for a window write, refuses a stale one and writes nothing` |
| Overlapping windows of one menu; a malformed hour; from equal to until; no day, a day outside 1 to 7; a window of a menu not in the list | `422 catalog.menu_windows_overlap` naming each window that overlaps an earlier one (`windows[2]`), midnight and Sunday to Monday included; `400 foundation.validation_failed` at the field (`windows[0].from`, `windows[0].until`, `windows[0].days[0]`, `windows[0].menuId`); nothing written | `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `teaches what is wrong with a window: overlap is 422 at the later window, the rest is 400 at the field`; `src/modules/catalog/domain/outlet-menus/menu-window.spec.ts` › `points at each later window that shares a minute with an earlier one`; `src/modules/catalog/domain/outlet-menus/menu-window.spec.ts` › `follows a window from Sunday into Monday` |
| Windows that touch; windows of different weekdays at the same hours | accepted | `src/modules/catalog/domain/outlet-menus/menu-window.spec.ts` › `lets windows touch: the end of one is the start of the next`; `src/modules/catalog/domain/outlet-menus/menu-window.spec.ts` › `lets windows of different weekdays share the same hours` |
| More windows than the limit; a window that crosses midnight; no days | 14 per menu, the first one over it refused at its position; a window that crosses midnight is taken; a window without days opens every weekday | `src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts` › `refuses more windows for one menu than the limit, pointing at the first one over it`; `src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts` › `takes a window that crosses midnight`; `src/modules/catalog/presentation/dtos/outlet-menus/outlet-menus.schemas.spec.ts` › `opens a window without days every weekday` |
| A window of a menu of another organization | `404 catalog.not_found`, nothing written | `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `refuses a window of a menu of another organization like any menu of another organization` |
| A cashier, and a manager of another outlet; the plan; a closed outlet | the same plain `403 catalog.action_forbidden`, audited; `403 platform.capability_not_entitled`; `422 authorization.outlet_not_operational`; clearing still works | `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `answers a cashier, and a manager of another outlet, the same plain denial for a window write and writes nothing`; `test/e2e/catalog-outlet-menu-windows.e2e-spec.ts` › `gates a window write by the plan and refuses it at a closed outlet, but lets the windows be cleared` |
| The weekdays and the hours the till shows each menu | before the window, at the exact start (inclusive), one second before the end, at the exact end (exclusive) when the next menu starts, after the last window, on Friday night and Saturday at 01:59 inside the window that crosses midnight, on Saturday at 02:00, a Sunday; `menusValidUntil` names the next change in each | `test/e2e/catalog-till-menu-windows.e2e-spec.ts` › `%s`; `src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts` › `%s` |
| A menu without a window; two menus that overlap in time; none open | a menu without a window is always shown and nothing names a next change; both overlapping menus are shown in the outlet order; `menus: []` and every item still sold | `test/e2e/catalog-till-menu-windows.e2e-spec.ts` › `shows a menu without a window always and names no next change when nothing is scheduled`; `test/e2e/catalog-till-menu-windows.e2e-spec.ts` › `shows two menus whose windows overlap, in the outlet order`; `test/e2e/catalog-till-menu-windows.e2e-spec.ts` › `answers menus [] when none is open and still sells every item` |
| Touching windows of one menu | no change is named at the instant one ends and the next starts | `src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts` › `does not name a boundary the set does not change at: a window that continues into the next one` |
| The tag moves with the clock, never answers `304` for a stale set | the same set at two instants answers `304`; the old tag no longer does once the clock opens or closes a window, with no write; the same open menus on another day carry another tag | `test/e2e/catalog-till-menu-windows.e2e-spec.ts` › `moves the tag when the clock opens or closes a window, without any write, and answers 304 only for the set it was read at`; `src/modules/catalog/application/till/sellable-entity-tag.spec.ts` › `names the menus shown now and the instant they next change, so it moves with the clock and never with a write` |
| One instant for the whole read | the clock is read once and both statements (the one that decides a `304` and the one that builds the body) are asked at that instant; their tag segments agree | `src/modules/catalog/application/till/read-what-an-outlet-sells.use-case.spec.ts` › `asks the clock once and reads the outlet and the menus of the till at that same instant`; `src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts` › `answers the same tag segments to the state read that decides a 304 and to the read that builds the body` |
| Daylight saving time; a site that changes its time zone | the window follows the wall clock on the day it changes, the autumn hour that happens twice is open twice, a plain day is judged in local time on both sides; `menusValidUntil` names the clock change itself when the next boundary lies beyond it, in the first pass of the repeated hour, after it and when a window starts in the hour a spring change skips; the same instant in the new zone shows other menus and the tag moves | `test/e2e/catalog-till-menu-windows.e2e-spec.ts` › `follows the wall clock of a site with daylight saving time on the day the clock changes`; `test/e2e/catalog-till-menu-windows.e2e-spec.ts` › `reads the same instant in the new local time when the site changes its time zone, and moves the tag with it`; `src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts` › `opens a window whose start was skipped by the spring clock change when the clock reaches it, and names the instant of the clock change`; `src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts` › `follows the wall clock through the autumn hour that happens twice`; `src/modules/catalog/infrastructure/till/till-menu-windows.repository.integration-spec.ts` › `evaluates a plain day in local time on both sides of the change` |
| `menusValidUntil` through the autumn hour that happens twice (America/New\_York, 2026-11-01), before it, at the start of the window in the first pass, in the last second of the first pass, after the clock went back, in the second pass and after the window | `05:30:00Z`, `06:00:00Z`, `06:00:00Z`, `06:30:00Z`, `07:30:00Z` and the next day `06:30:00Z`, with the menus shown at each instant | `src/modules/catalog/infrastructure/till/till-menu-windows.clock-change.integration-spec.ts` › `a window of 01:30 to 02:30 %s` |
| A window that crosses midnight over the clock change | inside it before the clock goes back the clock change is named (`06:00:00Z`), then its end at `07:00:00Z` and its next start | `src/modules/catalog/infrastructure/till/till-menu-windows.clock-change.integration-spec.ts` › `a window of 22:00 to 02:00 that crosses midnight and the clock change %s` |
| The next window boundary lies after the clock change | the clock change itself, then the exact boundary once the clock went back | `src/modules/catalog/infrastructure/till/till-menu-windows.clock-change.integration-spec.ts` › `names the clock change itself when the next window boundary lies after it, and the exact boundary once the clock went back` |
| A zone without a clock change (America/Guayaquil) on the same date | the boundary of the wall clock, no cap | `src/modules/catalog/infrastructure/till/till-menu-windows.clock-change.integration-spec.ts` › `a window of 01:30 to 02:30 %s` |
| Menus never limit what is sold | the same items are sold at every hour | `test/e2e/catalog-till-menu-windows.e2e-spec.ts` › `never limits what the till sells: the same items are sold at every hour` |
| SQL tables | a window is consistent (`from <> until`, `spans_midnight` exactly when `from > until`), numbered 0 to 13, with weekdays 1 to 7 once each; it needs the assignment of its menu at its outlet and its window for its weekdays; no `UPDATE` for the runtime; one version move per statement; another organization sees and deletes nothing; the operating context names the site time zone | `database/tests/contracts/catalog-outlet-menu-windows-contracts.ts` › `a window that stays inside one day is stored`; `database/tests/contracts/catalog-outlet-menu-windows-contracts.ts` › `the operating context of an outlet names the time zone of its site`; `database/tests/contracts/organization-published-reads-contracts.ts` › `the context follows a site whose time zone changes` |
| Plan | every probe of the schedule is a keyed lookup on the primary keys and no window table is read whole | `src/modules/catalog/infrastructure/till/till-menus.plan.integration-spec.ts` › `decides which menus are open now and when that changes by the key of each assignment, reading no window table whole` |

## Variant dimensions

Slice K13 (migration `0109`, baseline revision 119): structured variant dimensions (`CATALOG_USE_CASES.md`, "K13 as built"; `CATALOG_OPTIONS_V2.md`, "K13 as built"). The matrix that writes them is C6; the item read that shows them is C3; the list below offers them as filters.

### C32 — List the variant dimensions of the catalog

`GET /catalog/variant-dimensions` with an optional `businessUnitId`; needs `catalog.items.view` organization-wide or at that business unit, never the plan. Cacheable: the strong ETag is a hash of the answer.

| Case | Expected answer | Proving test |
|---|---|---|
| A catalog that never had a matrix | `200` with an empty list, `Cache-Control: private, no-cache` and a strong tag | `test/e2e/catalog-variant-dimensions-read.e2e-spec.ts` › `answers an empty list for a catalog that never had a matrix, with a tag and private no-cache` |
| Two items with matrices that share a dimension and add a value | each active dimension once, with its active values in the order they were first sent and the number of active items that use it | `test/e2e/catalog-variant-dimensions-read.e2e-spec.ts` › `lists each dimension with its active values in the order they were first sent and the number of active items that use it` |
| An item that is deactivated; the old tag | the count drops, the tag changes, the old `If-None-Match` answers `200`; the same tag answers `304` with no body | `test/e2e/catalog-variant-dimensions-read.e2e-spec.ts` › `counts only the active items, and its tag follows what it shows: a deactivated item lowers the count and the old tag answers 200` |
| Another organization | it sees only its own dimensions | `test/e2e/catalog-variant-dimensions-read.e2e-spec.ts` › `shows an organization only its own dimensions` |
| A cashier | `403 · catalog.action_forbidden`, the plain denial | `test/e2e/catalog-variant-dimensions-read.e2e-spec.ts` › `refuses a member without catalog.items.view with the plain denial` |
| The statement | binds the organization, reads the default catalog, keeps active dimensions and values in a stable order and counts the dimensions of each active item once, by the key of the item | `src/modules/catalog/infrastructure/variant-dimensions/variant-dimension-read.queries.spec.ts` › `counts the dimensions of each active item of the organization once, at most three by the key of the item, never with EXISTS nor OFFSET` |
| The plan | on 2,001 linked items among 20 other tenants with 3,000 dimensions, no table is scanned whole and the cost stays inside its measured bound | `src/modules/catalog/infrastructure/variant-dimensions/variant-dimension-read.plan.integration-spec.ts` › `reads the two dimensions of a catalog of 2,001 linked items by their keys, never scanning a whole table` |
| The shape of the answer | codes and names only, in a strict schema | `src/modules/catalog/presentation/dtos/variant-dimensions/variant-dimension-response.spec.ts` › `answers the dimensions as they are, with the codes and names of their values and the count of active items` |

## Product brands

Slice K15c (migration `0112`, baseline revision 122): the manufacturer brand of an item (`CATALOG_USE_CASES.md`, "K15c as built"; `DECISIONS.md` D38).

### C38 — Manage the product brands and give them to items

The brand routes need `catalog.items.manage` organization-wide; `productBrandId` on the item create and edit follows the item permissions.

| Case | Expected answer | Proving test |
|---|---|---|
| A brand created once per key, read, read again with its tag | `201` then replay; strong tag the read repeats and `304` on revalidation | `test/e2e/catalog-product-brands.e2e-spec.ts` › `creates a brand once per key, answers it with a strong tag the read repeats and revalidates it with 304` |
| A name another brand holds in any case, archived ones included; a malformed body | `409`/`400`, nothing written | `test/e2e/catalog-product-brands.e2e-spec.ts` › `refuses a name another brand holds in any case, archived brands included, and a malformed body` |
| A brand renamed under its tag; same name; stale tag; taken name | `200` and one audit record; the same name writes nothing; `412`; `409` | `test/e2e/catalog-product-brands.e2e-spec.ts` › `renames a brand under its tag, writes and records nothing for the same name, refuses a stale tag and a name that is taken, and lets a brand change the case of its own name` |
| A brand archived and restored; renaming an archived brand | `200` per step, a step in effect answers `200`; rename refused | `test/e2e/catalog-product-brands.e2e-spec.ts` › `archives and restores a brand, answers a step already in effect with 200, and refuses to rename an archived brand` |
| The brands listed, filtered by status; another organization | ordered by lowercase name; another organization sees none | `test/e2e/catalog-product-brands.e2e-spec.ts` › `lists the brands by lowercase name, filters them by status and shows another organization none of them` |
| Two parallel creations of one name | one `201`, one `409` | `test/e2e/catalog-product-brands.e2e-spec.ts` › `lets one of two parallel creations of one name win and answers the other 409` |
| A cashier, a waiter, a kitchen member, a unit manager | `403`, the plain denial; nothing written | `test/e2e/catalog-product-brands.e2e-spec.ts` › `refuses a cashier, a waiter, a kitchen member and a manager of one unit every brand command and read with the plain denial, writing and recording nothing` |
| An item created with a brand, replayed, renamed brand | `201` with `productBrand { id, name }`; replay; the new name and a new tag after the rename; a different brand under the key `409` | `test/e2e/catalog-item-brands.e2e-spec.ts` › `creates an item with a brand, answers it as an id and a name only on an item that has one, and shows a renamed brand on the item at once` |
| A brand given, kept, moved and cleared with null | `200` each; the same brand writes nothing; `productBrand` absent after null | `test/e2e/catalog-item-brands.e2e-spec.ts` › `gives an item a brand, changes it, clears it with null, writes and records nothing for the one it has, and records the ids before and after` |
| An unknown or foreign brand; an archived brand; a malformed id | `404 · catalog.not_found`; `422 · catalog.product_brand_archived`; `400`; nothing written | `test/e2e/catalog-item-brands.e2e-spec.ts` › `refuses a brand that does not exist or belongs to another organization with 404 and an archived one with 422, writing nothing` |
| A brand archived after the item took it | the item keeps it, is listed under it and can still be edited | `test/e2e/catalog-item-brands.e2e-spec.ts` › `lets an item keep a brand that is archived afterwards, and lists it under that brand` |
| The list filtered by productBrandId, with a text, paged | only that brand; the cursor is bound to the brand (`400 · collection.cursor_incompatible` for another) | `test/e2e/catalog-item-brands.e2e-spec.ts` › `lists the items of one brand, combined with a text, and binds a cursor to the brand it was issued for` |
| Two edits of the brand at one version | one `200`, one `412` | `test/e2e/catalog-item-brands.e2e-spec.ts` › `lets one of two edits of the brand of an item at the same version win and answers the other 412` |
| Two brands renamed at once to the same name | one `200`, one `409` (the unique index decides) | `test/e2e/catalog-product-brands.e2e-spec.ts` › `lets one of two brands renamed at once to the same name win and answers the other 409, by the unique index` |
| A brand archived while an item takes it | the archive is `200`; the item gets `200` (and keeps the brand) or `422 · catalog.product_brand_archived` (and holds none), never an archived brand it did not already have | `test/e2e/catalog-item-brands.e2e-spec.ts` › `never leaves an item holding a brand that was archived before the item took it: the brand is locked while the item reads it` |
| Two edits of the regulatory flags at one version | one `200`, one `412` | `test/e2e/catalog-regulatory-flags.e2e-spec.ts` › `lets one of two edits of the regulatory flags at the same version win and answers the other 412` |
| Two edits of the net content at one version | one `200`, one `412` | `test/e2e/catalog-net-content.e2e-spec.ts` › `lets one of two edits of the net content at the same version win and answers the other 412` |

## Variant identifiers

Slice K15d (migration `0113`, baseline revision 123): the manufacturer identifiers of a variant (`CATALOG_USE_CASES.md`, "K15d as built"; `DECISIONS.md` D39).

### C39 — Declare the manufacturer identifiers of a variant

Written inside the existing variant writes (item create, variant add, variant edit) with their permission and plan gates.

| Case | Expected answer | Proving test |
|---|---|---|
| An item created whose default variant holds identifiers; the same request again; another set under the key | `201` with `identifiers` in the order of the vocabulary; the replay answers the same item; another set `409` | `test/e2e/catalog-variant-identifiers.e2e-spec.ts` › `creates an item whose default variant holds identifiers in the order of the vocabulary, replays the same request and refuses another set under the key` |
| A variant added with identifiers, replaced, cleared and sent again with the set it holds | `201`, `200`, `200`; the same set is already in effect (same tag); `identifiers` is absent once cleared; the audit holds added, updated and updated | `test/e2e/catalog-variant-identifiers.e2e-spec.ts` › `adds a variant with identifiers, replaces the set, clears it, writes nothing for the set it holds and records the set before and after` |
| Two variants with the same identifier; a variant that never had any | both accepted (no uniqueness across variants); the read shows no `identifiers` key on a variant without them | `test/e2e/catalog-variant-identifiers.e2e-spec.ts` › `lets two variants hold the same identifier and shows no identifiers on a variant that never had any` |
| An unknown scheme, a repeated scheme, a blank or longer than 100 value, a null set | `400 · foundation.validation_failed`, nothing written | `test/e2e/catalog-variant-identifiers.e2e-spec.ts` › `refuses an unknown scheme, a repeated scheme, a blank or long value and a null set with 400, writing nothing` |
| Two replacements of the identifiers at one version | one `200`, one `412` | `test/e2e/catalog-variant-identifiers.e2e-spec.ts` › `lets one of two replacements of the identifiers at the same version win and answers the other 412` |
| The identifiers as a set | ordered by the vocabulary, equal sets compared by pair, one value per scheme | `src/modules/catalog/domain/items/variant-identifiers.spec.ts` › `judges two sets the same when they hold the same pairs in any order, and different when a value differs` |
| The schema | scheme, trimmed value of 1 to 100 characters, no repeated scheme | `src/modules/catalog/presentation/dtos/items/variant-identifier-fields.spec.ts` › `points at an unknown scheme, a repeated scheme, a blank, a long or NUL value and an unknown key` |

## Change feed

Slice K12a (migration `0114`, baseline revision 124): the typed events every catalog write leaves in the outbox, with their tombstones (`CATALOG_USE_CASES.md`, "K12a as built"; `DECISIONS.md` D40).

### C40 — Announce a catalog change to the devices

Written by triggers in the transaction of the write; no route, no permission and no plan gate of its own.

| Case | Expected answer | Proving test |
|---|---|---|
| An item created over HTTP | every version the create produced is announced, in publication order, with an item event naming the item and a variant event naming its default variant | `test/e2e/catalog-change-feed.e2e-spec.ts` › `announces every version a create produced, with the item and its variant` |
| An item archived, then restored | the archive writes a `retired` item event with `status` `archived` and `retiredAt`; the restoration writes an `upserted` event with no `retiredAt` | `test/e2e/catalog-change-feed.e2e-spec.ts` › `tombstones an archived item and announces its restoration as an upsert` |
| Two groups offered on an item, then one of them left out | offer events name the new offers; the group left out writes one `retired` offer event for its offer id | `test/e2e/catalog-change-feed.e2e-spec.ts` › `announces an offer, then tombstones it when the group is no longer offered` |
| A modifier group created with two options | every version is announced by `catalog.modifier.changed` events that name the group | `test/e2e/catalog-change-feed.e2e-spec.ts` › `announces the modifier group a create wrote, with its options` |
| The same create sent again with the same key | `Idempotent-Replayed: true`, no event, no outbox row and no version move; the first answer announced the item once | `test/e2e/catalog-change-feed.e2e-spec.ts` › `writes nothing for a replayed create and its first answer announced the item once` |
| Four parallel creates | four `201`; distinct increasing versions, every version announced, contiguous publication positions, one item event per item | `test/e2e/catalog-change-feed.e2e-spec.ts` › `gives parallel commands distinct increasing versions and loses no event` |
| A business unit registered | the listing event of its default assortment is written when the unit commits, naming the unit | `test/e2e/catalog-change-feed.e2e-spec.ts` › `announces what a newly registered business unit lists, written when the unit commits` |
| The offer edit that fails after its write | no offer, audit record, event, outbox row or version move survives; the successful edit before it had announced its changes | `test/e2e/catalog-modifier-offer-identity.e2e-spec.ts` › `leaves the offers, their ids, the audit trail, the announced changes and the version untouched when the record fails after the write` |
| An item and its default variant in one transaction | an item event then a variant event, each at the version its statement produced, with the outbox holding one row per event | `database/tests/contracts/catalog-change-feed-contracts.ts` › `each event carries the catalog version its statement produced` |
| Archiving an item; delisting it | `retired` with `status`, `retiredAt` and the item id; delisting is an `upserted` event with `status` `inactive` and no tombstone | `database/tests/contracts/catalog-change-feed-contracts.ts` › `archiving an item writes a retired event with a tombstone` |
| A category link; an option; a retired offer; an archived barcode; a deleted listing | an item event; a modifier group event; an offer tombstone; a barcode tombstone; a `removed` listing event with its scope | `database/tests/contracts/catalog-change-feed-contracts.ts` › `deleting a listing writes a removed event with its scope` |
| One statement over two categories | two events at the same version | `database/tests/contracts/catalog-change-feed-contracts.ts` › `one statement over two categories writes two events at the same version` |
| A privileged write outside a tenant context; a transaction that asked for immediate constraints | the first moves the version and writes no event; the second still writes its event | `database/tests/contracts/catalog-change-feed-contracts.ts` › `a privileged write outside a tenant context moves the version and writes no event` |
| A command that fails after its write; a write of another organization | no event, no outbox row, no version move; the other organization's feed is untouched | `database/tests/contracts/catalog-change-feed-contracts.ts` › `a command that fails after its write leaves no event, no outbox row and no version move` |
| The event function with an unknown event, another module's event, an unpublished version, a replayed key | refused with `23503` or, for the replay with another payload, refused; the same payload writes one event | `database/tests/contracts/catalog-change-feed-contracts.ts` › `the event api refuses an unknown event, another module and an unpublished version` |
| The runtime role against the event tables | refused on every insert and read, and on `events.append_domain_event` | `database/tests/contracts/catalog-change-feed-contracts.ts` › `the runtime is refused:` |
| Every table that bumps the version; the tables that do not | each writes events or is on the exclusion list; the feed trigger fires after the version trigger; the tables without version triggers are the translation, allergen, dietary and nutrition tables | `database/tests/contracts/catalog-change-feed-contracts.ts` › `every table that bumps the version writes events or is on the explicit exclusion list` |
| Two commands that lock the version row | distinct increasing versions, events in the same order, the second waits for the first | `database/tests/concurrency/catalog-change-feed-concurrency.ts` › `NINAKU_CATALOG_CHANGE_FEED_CONCURRENCY_OK: two commands that lock the catalog version row write distinct increasing versions and events in the same order, and the later one waits for the first to commit` |
| The payload of every event | a closed object per event: schema version 1, a positive integer version, the aggregate id, no unknown key, `retiredAt` exactly when the change is not an upsert | `src/modules/catalog/contracts/catalog-change-event.spec.ts` › `names nine events, each owned by the catalog` |

## Offline bootstrap

Slice K12b (no migration, baseline revision 124): the download of what an outlet sells, pinned to a cut, and its composition with the change feed (`CATALOG_USE_CASES.md`, "K12b as built"; `DECISIONS.md` D41).

### C41 — Bootstrap an outlet for offline work

`GET /catalog/outlets/{outletId}/bootstrap` and `.../bootstrap/items`, behind the till permission (`catalog.sellables.view` at the outlet), with no plan gate of its own and no record.

| Case | Expected answer | Proving test |
|---|---|---|
| An outlet with items that have SKUs and barcodes, one archived item and one archived barcode, walked in pages of two | the same items, in the same order and pages as the search walk, each variant with its `sku` and only its active barcodes, all at one cut | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `walks every item the outlet sells in the order and the pages of the search, each variant with its SKU and active barcodes, all at one cut` |
| The opening call, then the same page asked twice | `cut`, `catalogVersion` and the unit of the outlet with `private, no-cache`; the repeated page is byte-identical | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `answers the cut with the catalog version, the unit and the reference data, and a page of the same cut repeated answers the same page` |
| A scan of every barcode in the bootstrap index; a barcode of an item the outlet does not sell | the online scan at the outlet resolves the same variant; the index holds nothing the online scan answers `null` for | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `resolves a scan from the barcode index of the bootstrap to the variant the online scan at the outlet resolves, and knows nothing the outlet does not scan` |
| Two business units, one delisting an item; the cut of one used at the outlet of the other | each outlet bootstraps what its own unit sells; the crossed cut is `409 · catalog.bootstrap_cut_moved` | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `keeps the data of two business units apart: each outlet bootstraps what its own unit sells and refuses the cut of the other` |
| A missing, malformed or unknown `cut`, a limit of 0 or 101, `before`, an unknown parameter; a cursor used at another outlet | `400`, nothing read | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `refuses a cut that is missing, malformed or of another outlet, a cursor of another cut and an unknown parameter, reading nothing` |
| A cursor issued for an earlier cut, sent with the current cut; the same cursor with the earlier cut | `400` for the cursor (it is bound to its cut); `409 · catalog.bootstrap_cut_moved` for the earlier cut | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `refuses a cursor issued for an earlier cut with 400 even when the cut sent is the current one, and answers 409 for the earlier cut itself` |
| The query of a page | the cut parsed into its parts, the limit 1 to 100 with 50 by default, and `before`, an unknown parameter, a malformed cut or an empty cursor refused | `src/modules/catalog/presentation/dtos/bootstrap/bootstrap-query.spec.ts` › `parses the cut of an opening call into its three parts and defaults the page to 50` |
| A kitchen member, a bar member, a holder of `catalog.items.view` alone and a member without a role, at an existing, an unknown and a foreign outlet | the same plain `403` on both routes; nothing is recorded | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `refuses a kitchen member, a bar member, a holder of catalog.items.view alone and a member without a role the same plain denial on both routes at an existing, an unknown and a foreign outlet, and records nothing` |
| A cashier scoped to the outlet, whose scope then moves to another outlet | reads the outlet and its first page; the next page and a new opening are the plain `403` | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `lets a cashier scoped to an outlet bootstrap it and stops the next page the moment the scope moves to another outlet` |
| A member of another organization naming this outlet | `404 · organization.structure_not_found` | `test/e2e/catalog-bootstrap.e2e-spec.ts` › `refuses the same to a member of another organization naming this outlet` |
| A walk of 3,000 items in pages of 100 with a catalog write between two pages; the walk started again | the next page is `409 · catalog.bootstrap_cut_moved`; the new walk has 31 pages, every item once in order, no gap, and ends at the current version | `test/e2e/catalog-bootstrap-consistency.e2e-spec.ts` › `refuses the next page of a walk the moment a catalog write lands in the middle of it, and the walk started again ends at one cut with every one of the 3,000 items once, in order, with no gap` |
| The same page requested three times at once | the same body; no row, event, outbox row or version move | `test/e2e/catalog-bootstrap-consistency.e2e-spec.ts` › `reads, retries and repeats a page without writing a row, an event or a version` |
| The statements of an opening, a first page, a full page and a refused cut | 7, 8, 8 and 7 | `test/e2e/catalog-bootstrap-statement-budget.e2e-spec.ts` › `opens a bootstrap in 7 statements and reads a page in 8, whatever the page holds` |
| The SKU and barcodes of the variants of a sold item; an item the outlet archived; a variant that is not active | only the active barcodes; neither the archived item nor the inactive variant | `src/modules/catalog/infrastructure/bootstrap/bootstrap.repository.integration-spec.ts` › `answers the SKU and the active barcodes of each sold variant, nothing else about identifiers` |
| A page of 100 over 2,001 imported items with 2 barcodes each, before the statistics know the import | every variant and barcode read by its key; no sequential scan; rows and buffers within the ceiling per item | `src/modules/catalog/infrastructure/bootstrap/bootstrap.plan.integration-spec.ts` › `pages every imported item with its SKU and barcodes reading each variant and each barcode by its key, never the whole organization per item` |
| The use case with a moved catalog, structure or unit, or without the permission | the cut is refused before any item is read; the permission is decided first | `src/modules/catalog/application/bootstrap/read-outlet-bootstrap.use-case.spec.ts` › `refuses a member without the till permission before looking at the outlet or reading anything` |
| The cut as a value | one token per catalog version, structure version and unit, parsed back, malformed tokens refused | `src/modules/catalog/domain/bootstrap/bootstrap-cut.spec.ts` › `compares the three parts` |

### C42 — Apply the change feed to a bootstrap

The device applies the catalog events whose `catalogVersion` is greater than the cut. Serving the events is Sync's; the rules and the proof live here.

| Case | Expected answer | Proving test |
|---|---|---|
| A bootstrap, then 24 random commands (create, rename, deactivate, archive, restore, add a variant, add a barcode, archive a variant) and a fresh bootstrap, for three seeds | the bootstrap plus the feed events after its cut equals the fresh bootstrap, and every item that differs was announced by an event of the feed | `test/e2e/catalog-bootstrap-deltas.e2e-spec.ts` › `ends with the same items, variants, SKUs and barcodes as a fresh bootstrap, and every item that differs was announced (seed %i)` |
| An item archived, an item deactivated and a variant archived after a bootstrap | the device removes the item, the item and the variant with only the tombstones and the ids it holds, and agrees with a fresh bootstrap | `test/e2e/catalog-bootstrap-deltas.e2e-spec.ts` › `removes an archived item, a deactivated item and an archived variant from the device with nothing but the tombstone and the ids it already holds` |
| The version of an opened bootstrap against the feed | no event at or below it is missing from the data; every later event is above it | `test/e2e/catalog-bootstrap-deltas.e2e-spec.ts` › `opens a bootstrap whose version is the one every later event must exceed` |

## HTTP foundation

The first slice of the Catalog API v7 plan (`CATALOG_USE_CASES.md`, "API v7
foundation as built"): rules every catalog route inherits.

### F1 — Replace a collection only at the version read

| Case | Expected answer | Proving test |
|---|---|---|
| Each of the seven replace-the-collection `PUT`s without `If-Match`, with `*`, with a stale tag, with the current tag, then the same list with its new tag | `428 · precondition.required` with a detail that teaches the fix, twice; `412 · foundation.precondition_failed`; nothing written or recorded until the current tag: `200` with the tag `GET` answers and one record; the replay `200` with the same tag and no record | `test/e2e/catalog-required-preconditions.e2e-spec.ts` › `$route answers 428 without If-Match or with *, 412 with a stale tag and 200 with the current one, and a replayed list with its new tag answers 200 recording nothing` |
| Each of the seven `PUT`s without `If-Match`: no session; another organization; an invalid body | `401 · identity.session_required`; `403 · tenant.context_rejected`; `428 · precondition.required`, never `400` | `test/e2e/catalog-required-preconditions.e2e-spec.ts` › `$route without If-Match checks the session first (401), then the organization (403), then the precondition (428) before the body (never 400)` |
| The guard and its contract | a missing, blank or `*` header refused before the handler, a named tag let through; the header documented `required: true` with the `412` and the `428` | `src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts` › `refuses a write without If-Match with 428 before the handler runs, teaching the caller to send the ETag it read`; `src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts` › `refuses $label with 428`; `src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts` › `lets a write that names an entity tag through to the handler, which judges whether it is current`; `src/foundation/http/conditional-requests/requires-if-match.decorator.spec.ts` › `documents a required If-Match header, the 412 of a stale tag and the 428 of a missing one` |

### F2 — Answer only the meta a route documents

| Case | Expected answer | Proving test |
|---|---|---|
| Create, read, a replacement `PUT`, the item list, the tree, the group list, the workspace and the till read | each real body passes the schema the route documents, `meta` included | `test/e2e/envelope-meta-contract.e2e-spec.ts` › `answers single resources, writes, collections and the till read with bodies their documented schema accepts, meta included` |
| The same bodies with one undeclared `meta` key | refused by every documented schema | `test/e2e/envelope-meta-contract.e2e-spec.ts` › `refuses, against the same documented schemas, a body whose meta carries one key the route does not declare` |
| The composers | a single resource without meta documents a required empty object; declared keys close the object; a collection closes it to `pageInfo` and its keys | `src/foundation/openapi/api-envelope-response.decorator.spec.ts` › `documents the meta of a single resource that declares none as a required empty object`; `src/foundation/openapi/api-envelope-response.decorator.spec.ts` › `closes the meta of a single resource to the entries it declares`; `src/foundation/openapi/api-envelope-response.decorator.spec.ts` › `closes the meta of a collection to the page info and the entries it declares` |

### F3 — Bound the writes one member sends a minute

| Case | Expected answer | Proving test |
|---|---|---|
| A member past the limit; its reads; another member of the organization | `429 · rate_limit.exceeded` with a `Retry-After` of at most 60 seconds and a detail that says to wait; reads `200`; the colleague's write `201` | `test/e2e/catalog-member-write-throttle.e2e-spec.ts` › `refuses the write after the limit with 429 rate_limit.exceeded and a Retry-After, keeps answering that member reads, and lets another member of the organization write` |
| Ten rapid writes by a member without the permission | the first ones `403` with one denied record each, the rest `429` recording nothing | `test/e2e/catalog-member-write-throttle.e2e-spec.ts` › `bounds the audit records a member without the permission can leave: rapid denied writes turn into 429 after the limit and record nothing more` |
| The guard | counts per membership, never a read, falls back to the address without a tenant context | `src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts` › `lets a member write up to the limit and refuses the next write with 429 rate_limit.exceeded and a Retry-After within the window`; `src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts` › `never counts nor refuses a read, even once the member is refused`; `src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts` › `counts each member apart, so another member of the same organization keeps writing`; `src/foundation/http/rate-limiting/member-write-throttle.guard.spec.ts` › `falls back to the client address when no tenant context was resolved` |

### F4 — Accept a bulk body up to its route's ceiling

| Case | Expected answer | Proving test |
|---|---|---|
| The documented maximum of the menu structure, of the bundle groups, of the modifier group set (512 KiB) and of the item offers with 10 variant lists (512 KiB, the same parser), as compact JSON | accepted by the schema, at most the route's ceiling and above the step below it | `src/modules/catalog/presentation/controllers/shared/catalog-body-limits.spec.ts` › `lets $route take its documented maximum as compact JSON under the smallest step that holds it`; `src/modules/catalog/presentation/controllers/shared/catalog-body-limits.spec.ts` › `raises the ceiling only on the menu structure, the bundle groups, the modifier group set and the item offers` |
| The documented maximum of the bulk images, the variant matrix and the listings | accepted by the schema and under the 100 KB default | `src/modules/catalog/presentation/controllers/shared/catalog-body-limits.spec.ts` › `leaves $route on the 100 KB default, which already holds its documented maximum` |
| A maximal menu structure of about 2 MB; one byte over each ceiling; 100 KB plus one byte on the bulk images and the item edit | read and judged by the use case (`412` for the tag sent); `413 · payload.too_large`; `413 · payload.too_large`, while exactly 100 KB reaches validation (`400`) | `test/e2e/catalog-body-limits.e2e-spec.ts` › `reads a documented-maximum menu structure of about 2 MB to its end and judges it, instead of refusing its size`; `test/e2e/catalog-body-limits.e2e-spec.ts` › `refuses a body one byte over the ceiling of $route with 413 payload.too_large`; `test/e2e/catalog-body-limits.e2e-spec.ts` › `keeps the 100 KB default on every other route, the bulk image route and the item edit included` |
| 100 KB plus one byte on the menu structure and the bundle groups, with and without bearer credentials | with them, read and refused by the schema (`400 · foundation.validation_failed`), so the ceiling is mounted on the real route; without them `413 · payload.too_large` before any guard | `test/e2e/catalog-body-limits.e2e-spec.ts` › `reads a body one byte over the 100 KB default on $route and answers it with the schema (400), so its raised ceiling really is mounted on that route`; `test/e2e/catalog-body-limits.e2e-spec.ts` › `never reads more than 100 KB on $route from a caller without bearer credentials: 413 before any guard`; `src/foundation/http/body-limits/json-body-limits.spec.ts` › `leaves a body without well-formed bearer credentials to the 100 KB default, so an anonymous caller never makes the server read a raised ceiling` |
| The parsers | one JSON parser per ceiling for its method and path, JSON only, then the 100 KB default; mounted under the versioned prefix at bootstrap | `src/foundation/http/body-limits/json-body-limits.spec.ts` › `matches the method and every literal segment, a parameter matching any one segment, and ignores the query`; `src/foundation/http/body-limits/json-body-limits.spec.ts` › `mounts one JSON parser per ceiling for its JSON routes only, then the 100 KB default for every other JSON body`; `src/bootstrap/configure-http-application.spec.ts` › `mounts the raised JSON ceilings of the catalog bulk routes under the versioned prefix, then the 100 KB default` |

### F5 — Refuse an oversized menu tree early and answer with safe headers

| Case | Expected answer | Proving test |
|---|---|---|
| 51 top sections; 51 subsections in one section | `too_big` at `sections`, and nothing else; `too_big` at `sections[0].subsections`, and nothing else (the array check aborts before the tree is walked) | `src/modules/catalog/presentation/dtos/menus/menu-request.schemas.spec.ts` › `refuses a fifty-first top section at sections and a fifty-first subsection at its subsections with too_big alone, never walking the oversized tree` |
| `GET /api/v1/catalog/items` and its `401` | `X-Content-Type-Options: nosniff` and no `X-Powered-By` on both | `test/e2e/catalog-security-headers.e2e-spec.ts` › `answers GET /api/v1/catalog/items, and a refusal of it, with X-Content-Type-Options nosniff and no X-Powered-By`; `src/bootstrap/configure-http-application.spec.ts` › `hides the framework and mounts the security headers` |

## Gaps

None: slice K8 proved the last open row (an archived item refused by admission, C7).
