Skip to content

Catalog Case Matrix ​

One row per behavioral case for the catalog use cases built so far, named in 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. 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, 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 §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 §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 §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 §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 §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 §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). 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). 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). 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). 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). 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 CHECKs (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 cases42
Per-case scenario rows746
Per-case scenario rows proven746
Cross-tenant and concurrency invariants37 (37 proven)
Gaps0

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.

CaseExpected answerProving test
A catalog nobody wrote yet200, 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 movetest/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 items200; the page is the one GET /catalog/items answers with limit 50 and its cursor continues theretest/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 unit200 with the next page: the cursor binds the unittest/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 assortmentexactly active, inactive, archived; nullsrc/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 rowall, 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 actionsassortment.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 recordedtest/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 unit200, 403 · catalog.action_forbidden, 403; an unknown unit 404 · organization.structure_not_found only for an organization-wide readertest/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 unitthe same 403 · catalog.action_forbidden, nothing recordedtest/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 catalog200test/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 parenteach with its own status, isEffectivelyActive and blockedByAncestorId, derived from the tree it read, with no other statementtest/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 pagethe same primaryImage as the list, from one Assets callsrc/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 checksview permission, allowed actions, facts, tree, first page; nothing writtensrc/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).

CaseExpected answerProving test
Active items by lowercase name with the default variant and the variant count, forward and backward pages200, meta.pageInfo only, Cache-Control: private, no-cache, no ETagtest/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 qprefix only, case-insensitive, every character literal; 400 · foundation.validation_failed for the malformed qtest/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 onethe same items in key order, nothing for the literal wildcards, no gap or duplicate across pagestest/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 OFFSETas describedsrc/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 pagesfull pages from each cursor, no gap, no duplicate, the last page says sosrc/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 securityindex conditions on catalog_items_name_search_idx over a seeded catalogdatabase/tests/contracts/catalog-search-key-contracts.ts › is an index condition on catalog_items_name_search_idx under row level security
Status filteractive by default; inactive and archived only when askedtest/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 orderone order by name across them, page after page; the same cursor for inactive,active and active,inactive; 400 · collection.cursor_incompatible for another settest/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 statusesone ordered branch per status with its own limit, merged and limited again, the prefix judged per branch; one status keeps its single scansrc/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 descendantsthe category alone without includeDescendants; with it the two levels below too, each item once, nothing from a sibling tree; a cursor without it refusedtest/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 filterprimaryImage 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 imagestest/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 false400 · foundation.validation_failed pointing at status[1], includeDescendants or hasImagetest/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 planno 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 ceilingsrc/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 page7 whatever the filters, 8 with listed or with primary imagestest/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 categorythe items classified in the category; primaryCategory names the primary onetest/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 one400 · foundation.validation_failed; 400 · collection.cursor_tampered (or cursor_invalid), cursor_incompatible, cursor_expiredtest/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 cursorthe 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 pagesno gap, no duplicate, still orderedtest/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 organizationseach lists only its owntest/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 filterthe same 403 · catalog.action_forbidden, the unit never looked uptest/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 unit200 at its unit only; 404 · organization.structure_not_found only for an organization-wide readertest/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 plan200: reads are never gatedtest/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 errortest/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 dimensionsone 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 matchingtest/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 reactivatedthe item leaves the filtered list and comes backtest/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, statusthe intersection, in the usual ordertest/e2e/catalog-variant-dimensions-filter.e2e-spec.ts › combines with the status and the text filters
A cursor under another variantValue, or under nonethe cursor is bound to the filter: 400 · collection.cursor_incompatible; the same filter pages ontest/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 twice400 · foundation.validation_failed pointing at variantValue or variantValue[n] with a message that shows variantValue=talla:m and where the codes come fromtest/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 filterone 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 valuesrc/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 itemsevery 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.

CaseExpected answerProving test
Happy path: the item with its variants, the same body and ETag the create answered200, Cache-Control: private, no-cache, strong ETagtest/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 edit304 with no body, then 200 with the new tagtest/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 item200, status archivedtest/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 id404 · catalog.not_found, the same body for bothtest/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 organization200 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 unittest/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 nonethe same 403 · catalog.action_forbidden for all three: the permission is checked before the unit is looked up, so unit ids cannot be probedtest/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.view403 · catalog.action_forbiddentest/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 reclassifiedcategories[] joins the representation and its hash; the old If-None-Match answers 200test/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 deactivatedmodifierGroups[] joins the representation and its hash; the old If-None-Match answers 200 and the old If-Match 412test/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 changesvariants[].barcodes[] joins the representation and its hash; the old If-None-Match answers 200test/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 linkedvariants[].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 suspended403 · tenant.context_rejected, before any catalog questiontest/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 appearssrc/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.

CaseExpected answerProving test
The whole tree depth first, siblings by order then name, each with its depth200, strong ETag, Cache-Control: private, no-cache, meta: {}; 304 to the same tagtest/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 catalog200, no node, one stable strong tag, 304 to it strong or weak, nothing writtentest/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 filteronly that status, a kept child still names its excluded parent, another tag; an unknown status 400test/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 reactivatedown 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 inactivetest/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 outthe kept nodes still name their blocking ancestor, from one more hierarchy statementtest/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 hierarchythe 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 othertest/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 organizationthe tag stays for item writes and classifications; it changes for a category write; never for another organization; the old tag stops answering 304test/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 databaseone step per category statement, none for an update that leaves the shown columns as they were, none for item writes, guarded against forgingdatabase/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 renamea new tag, the old one answers 200test/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 planas C1test/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.

CaseExpected answerProving test
Happy path: a simple item, its default variant default, a code generated from the name201, the C3 shape, its ETag and Cache-Control: private, no-store, one catalog.item.created recordtest/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 sentsrc/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 case409 · catalog.code_taken, no constraint name in the bodytest/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 minimum409 · catalog.sku_taken, 422 · catalog.unit_unknown, 422 · catalog.quantity_policy_invalidtest/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 createrolls back with it: no catalog.item.created record survivestest/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 request400 · foundation.validation_failed; 201 with Idempotent-Replayed: true and the same body; 409 · idempotency.key_reusedtest/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 namebefore: 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_reusedtest/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 codeone 201, one 409 · catalog.code_taken, one itemtest/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 codeevery one 201, codes base, base-2, base-3…, no deadlocktest/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-Keyone item, one audit record, every other answer replayedtest/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 hashcompared with the item as it is now, as before: 201 replayed while unedited, 409 · idempotency.key_reused once editedsrc/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 null201 replayed (the hash is taken over the canonical request: keys sorted, categories sorted by id, quantities in their shortest form); 409 · idempotency.key_reusedsrc/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 arraysrefused with a TypeError instead of being hashed as something else; the order of nested arrays countssrc/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 hashrefused 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 createthe plain 403 · catalog.action_forbidden or 403 · platform.capability_not_entitled, never the replay, nothing of the first answertest/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 createsCache-Control: private, no-store and Idempotent-Replayed: true (only on a replay) in OpenAPI; the ETag stays in the operation textsrc/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 91409 · idempotency.key_reused: no comparator, never a replaysrc/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 unit403 · catalog.action_forbidden, one denied record, nothing written, nothing about the plantest/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.items403 · platform.capability_not_entitled, audited as deniedtest/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 list400 · foundation.validation_failed, nothing writtensrc/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 sizesthe create carries no named variants (an unknown variants field answers 400); the sizes are added with POST .../variants or POST .../variant-matrixtest/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 primary201 with categories[], primary first, in the transaction and the record of the creation; 201 replayed; 409 · idempotency.key_reusedtest/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 them422 · catalog.category_archived, 404 · catalog.not_found, 400; nothing created or recordedtest/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 409test/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 409test/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.

CaseExpected answerProving test
Rename, set and clear the description200 with the new ETag; one catalog.item.updated record with each change before and aftersrc/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 stored200, nothing written or auditedsrc/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 item409 · catalog.code_takensrc/modules/catalog/infrastructure/items/item.repository.integration-spec.ts › refuses renaming the code onto a code another item holds
A stale If-Match412 · foundation.precondition_failed, nothing writtensrc/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-Matchone 200, one 412, one audit recordtest/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 item422 · catalog.item_archived until it is restoredtest/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 UUID400 · foundation.validation_failedsrc/modules/catalog/presentation/dtos/items/update-item-request.schema.spec.ts › refuses %s
A plan without catalog.items403 · platform.capability_not_entitledtest/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 organization404 · catalog.not_foundtest/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 statementsnever name item_id, organization_id, catalog_id or created_at in their SET listsrc/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 aftertest/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 requestone write of each under the item lock and one audit record holding both changessrc/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 list400 · 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 tillone 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.

CaseExpected answerProving test
Add a named variant with If-Match; the same code in any case; a stale tag201, Cache-Control: private, no-store, new tag, one catalog.item.variant_added; 409 · catalog.code_taken; 412 · foundation.precondition_failedtest/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 maximum200 with one catalog.item.variant_updated per real change; 409 · catalog.sku_taken; 422 · catalog.unit_unknown; 422 · catalog.quantity_policy_invalidtest/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 repeatexactly one default; 422 · catalog.variant_name_required without formerDefaultName; a repeat answers 200 and records nothingtest/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 route200, one catalog.item.updated then one catalog.item.default_variant_changed; 404 · catalog.not_found; the old route 404test/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 defaultVariantId422 · catalog.variant_not_active; one transaction, so the name is not kept, the ETag is unchanged and no catalog.item.updated record is writtentest/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 default422 · catalog.default_variant_required; 422 · catalog.variant_not_active; the former default can be archived once another is the defaulttest/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; repeats200 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 movestest/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 includedlocks 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_requiredsrc/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 item422 · catalog.item_archived until the item is restoredtest/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 again201 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 writtentest/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 record201; 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 namethe dimension is created once; a value that exists keeps its stored code and name, a new one is appendedtest/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 dimensions200 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 effecttest/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 asks409 · catalog.code_taken, nothing linkedtest/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 setthe 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 sendtest/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 one409 · catalog.variant_combination_taken whose detail says to deactivate or archive the other variant, nothing written; the database refuses the same state at committest/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 oncethe 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_lockedtest/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 code400 · foundation.validation_failed pointing at dimensions[n].namesrc/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 matrix18 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 100test/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 variants409 · catalog.code_taken, 409 · catalog.sku_taken, 422 · catalog.item_variants_full, nothing writtentest/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 variant409 · catalog.code_taken, nothing created: an archived variant is never the same matrixtest/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 dimension400 · foundation.validation_failedtest/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 idsthe same 403 · catalog.action_forbidden, one denied record each, nothing written, the item tag unchangedtest/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 planadd, edit, default selection, matrix, reactivate and restore 403 · platform.capability_not_entitled; deactivate and archive 200test/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 organizationId404 · catalog.not_found like an unknown id, nothing changed; 400 for the forged fieldtest/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 statementsnever name item_id, organization_id, catalog_id, created_at, item_variant_id or the barcode value in a SET listsrc/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 size201; the unnamed default stays the usual portiontest/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 recordstest/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 zero422 · catalog.unit_unknown, nothing written; 400 · foundation.validation_failed at the missing field or the quantitytest/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 edit422 · catalog.quantity_policy_invalid; a policy on the step is acceptedtest/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 contentanswers none of the two keys, byte-identical to before; the database holds no half of a net contenttest/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 onlydatabase/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).

CaseExpected answerProving test
Each legal transition200 with the item: active → inactive → archived → inactive → active, one record per changesrc/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_invalidtest/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 route200 with exactly { itemId, status } and the ETag a read answers; 412 · foundation.precondition_failed; 400 · foundation.validation_failed; 404test/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 teachesthe detail names the current status and the transitions it allows, from the lifecycle tablesrc/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 effect200, nothing written or auditedsrc/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
Restorationback to inactive, never straight to activesrc/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.itemsdeactivation and archival 200; reactivation and restoration 403 · platform.capability_not_entitledtest/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.manage403 · catalog.action_forbidden on every transition, auditedtest/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 organization404 · catalog.not_found on every transitiontest/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 searchtest/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 linetest/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.

CaseExpected answerProving test
Classify, switch the primary, empty the set; repeat the same set200 with the item and its tag, one catalog.item.categories_set record per real change, none for a repeattest/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 item422 · catalog.primary_category_conflict, 422 · catalog.category_archived, 404 · catalog.not_found, 400, 400, 412 · foundation.precondition_failed, 422 · catalog.item_archived; nothing writtentest/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 added201, 200, 200; 422 · catalog.category_archived, nothing written or recordedtest/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 repository404 · catalog.not_found or 422 · catalog.primary_category_conflict, never a constraint namesrc/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 fieldaccepted; 400src/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 plan403 · catalog.action_forbidden; 403 · platform.capability_not_entitledtest/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.

CaseExpected answerProving test
Create a root and a child; a code taken in any case201 with the category and its depth, one catalog.category.created each; 409 · catalog.code_taken, no constraint nametest/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 limit201; 422 · catalog.category_limit_reached, nothing written or recorded, the tree still answered whole (meta: {}); 200, a reactivation creates nothingtest/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 again200, one catalog.category.updated per real change, none for a repeattest/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 parent200; 422 · catalog.category_cycle; 422 · catalog.category_depth_exceeded; 422 · catalog.category_archived; 404 · catalog.not_found; nothing writtentest/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 category200, one record per real change; 422 · catalog.transition_invalid; 422 · catalog.category_archivedtest/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 parenteach written and recorded once after the version row and the categorysrc/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 transition422 · 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; 400test/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 root422 · 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 backstoptest/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 root200 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; 200test/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 statusthe refusal is audited as catalog.category.moved; 400; 400src/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 down422 · catalog.category_depth_exceeded, no stack overflowsrc/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 it201 and 200, each answer carrying isEffectivelyActive and blockedByAncestorId = that parenttest/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 child422 · catalog.category_archived, nothing written; 200, the child active but hidden by the archived parent, whose status is never judgedtest/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 ancestorsthe item and its variants keep status active; the item list still filters by the categorytest/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 edit400 · foundation.validation_failed, nothing writtentest/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 unit403 · catalog.action_forbidden, one denied record each, nothing writtentest/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 plancreate, edit, move, reactivate and restore 403 · platform.capability_not_entitled, the archived category left archived; deactivate and archive 200test/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 organization404 · catalog.not_found on every command, identical to an unknown idtest/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 create400 · foundation.validation_failed, nothing writtentest/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 renamed201, Idempotent-Replayed: true, the category as it is now with its current ETag, nothing written or recordedtest/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 limit201 replayed: the key is looked up right after the version row, before the limit, the parent and the codesrc/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 key409 · idempotency.key_reused, whatever the category looks like now; 409 · catalog.code_taken; nothing writtentest/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 trailan independent 201 there; never answered, never recordedtest/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 requestsone category, one record, every other answer 201 replayed; one 201, the others 409 · idempotency.key_reused; no 500test/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 rename200, 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, archivethe 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 replaytest/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 tag412 · 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 conditionaltest/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 deactivatedthe category's tag changes, because isEffectivelyActive, blockedByAncestorId and depth are part of what it answers; its old tag answers 412test/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 hierarchyGET /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 othertest/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 id200; the same plain 403 · catalog.action_forbidden before any lookup; the same 404 · catalog.not_found; 400test/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 organizationboth 201, independent; each replay answers its own resourcetest/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-Match412 · foundation.precondition_failed, nothing written, the tag unchangedtest/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 answerthe tag hashes exactly the answered fields in their order, so it must change with itsrc/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.

CaseExpected answerProving test
Create a group with instruction and variant options; read it201, 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-Matchtest/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 answer400 · 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 limit201; 422 · catalog.modifier_group_limit_reached, nothing written, the list still answered whole (meta: {}); 200test/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 one409 · catalog.code_taken with no constraint name; 404 · catalog.not_found; 422 · catalog.variant_not_active; nothing writtentest/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 options400 · foundation.validation_failed, nothing writtentest/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 repeated200 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; 400test/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 it200 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 readabletest/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 allowssrc/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 status200 ordered by name with optionCount and activeOptionCount, meta: {}; 400test/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-Match200 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; 412test/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 one404 · catalog.not_found, 404, 422 · catalog.variant_not_active, each pointing at options[i].variantId; nothing writtentest/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 target200, 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; 400test/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 option200 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 inactivetest/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 optionthe 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 backtest/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 counted422 · catalog.modifier_group_full, no row, no record, the group tag unchangedtest/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 group422 · catalog.modifier_group_archived, the group tag unchangedtest/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 commandthe same 403 · catalog.action_forbidden, one denied record each, nothing writtentest/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 unit403 · catalog.action_forbidden, nothing recorded; 200 at its unit, 403 without ittest/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 plancreate, 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 200test/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 ids404 · catalog.not_found, identical to an unknown id; the foreign group unchangedtest/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 to403 · tenant.context_rejected before any catalog lookuptest/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 organization400; 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 recordedtest/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 keyone group or one option, one record, the others 201 replayed; one 201, the others 409 · idempotency.key_reused; no 500test/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 catalogthe plain denial or the entitlement code, never the replaytest/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; businessUnitId200, 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 unittest/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 offer200 with an empty page and null cursors; 200 with its items, history kepttest/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 backno gap and no duplicate among the items that offered it first, in name order; the page before the second equals the firsttest/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 organization400 · foundation.validation_failed; 400 · collection.cursor_tampered; 400 · collection.cursor_incompatibletest/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 cursorthe same 403 · catalog.action_forbidden, decided before the cursor or any lookup, nothing recordedtest/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 unit200, and 404 · catalog.not_found for an unknown group; the same plain 403 for every other unit, existing group or nottest/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 unitthe identical 404 · catalog.not_found; 404 · organization.structure_not_foundtest/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 plan200: reads never ask about the plantest/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 pagetest/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 cleared200 each time; cleared, the option answers no presentation key again and the tag moves with every real changetest/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 option201 with kind: "text" and maxQuantity 1, and the instruction with prefix: "extra" and maxQuantity 3test/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 option422 · catalog.modifier_option_presentation_invalid listing each option by name and the rule at options[n].textMaxLength, .maxQuantity or .prefix, nothing writtentest/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 variant400 · foundation.validation_failed at options[n].textMaxLength, .prefix, .printsOnReceipt or the optiontest/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 versionone 200 and one 412, the group holds one text optiontest/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 kindmodifier_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 ondatabase/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.

CaseExpected answerProving test
A required single choice and an optional multiple choice, in order; the same list again; a stale tag; an empty list200 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 grouptest/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 groups422 · catalog.selection_rule_invalid; 422 · catalog.modifier_group_not_active; 400; nothing writtentest/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 default422 · catalog.default_options_invalid, the item tag unchanged; 200 with defaultOptionIdstest/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 ordereach 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 tagtest/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 defaultsthe same 422 · catalog.default_options_invalid for all four; 400 · foundation.validation_failed; 400, since a group holds at most 50 optionstest/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 defaultthe same 422 · catalog.default_options_invalid: the command judges defaults with the structural activeOption of the tilltest/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 counted200; with the target paused, a changed rule whose minimum the options left cannot reach answers 422 · catalog.selection_rule_invalid and a reachable one 200test/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 defaults200, 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 idtest/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_invalidtest/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 judgedtest/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 writtensrc/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 defaultstest/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 optiontest/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 judgedtest/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 writtensrc/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 acceptedtest/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 nowsrc/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 keytest/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 item200; no status of the item or of the group changestest/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 reorders200 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 locktest/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 unknown422 · catalog.item_archived; 404 · catalog.not_found; nothing writtentest/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 deactivatedevery read carries the groups in order with their options; the tag changes twice and the first one stops matchingtest/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_unsatisfiabletest/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 itemabsent 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 refusedtest/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 grouptest/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 idtest/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 rowtest/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 PUTs 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 ACTIONsrc/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 unchangedtest/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 deleteddatabase/tests/contracts/catalog-offers-never-deleted-contracts.ts › NINAKU_CATALOG_OFFERS_NEVER_DELETED_OK
The published shape of an offered groupexactly the stored values, accepted by the strict schemasrc/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: true200 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 again200 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 nothingtest/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 lists404 · 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 writtentest/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-Match200 with the same tag and no record; 412 · foundation.precondition_failed, nothing writtentest/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 writtentest/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 againtest/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 unchangedtest/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 session401 · identity.session_required before any lookuptest/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 writtentest/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 theretest/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 storedtest/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 gatedsrc/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 offertest/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 offertest/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 groupsrc/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 indexsrc/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 writtensrc/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 identitysrc/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 untouchedsrc/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).

CaseExpected answerProving test
Set two barcodes, one suspended; replay the same set in another order; the same value on another item; another organization200 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 elsewheretest/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 freedarchived, 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 suspendedtest/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 demoted200 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 twotest/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; 21200, the archived ones do not count; 400 · foundation.validation_failedtest/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 value422 · 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 writtentest/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 item200 without asking the plan; 403 · platform.capability_not_entitled; 422 · catalog.variant_archived, 422 · catalog.item_archivedtest/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 variantits 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 nothingtest/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 itthe active barcodes of all its variants are deactivated and recorded once; restoration reactivates none; the values can then be set on another itemtest/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 encodingsa 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 formtest/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 catalogtwo retired barcodes may share a value; a second active one is a unique violationdatabase/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 SQLthe 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 onlydatabase/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.requiredtest/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 auditthe 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 nothingsrc/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 budget15 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 entrythe catalog code, its status, meta.errors[] with the field and a message that says how to fix it; only the leading kind is listedsrc/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.

CaseExpected answerProving test
A cashier scans a listed variant; unknown value, suspended or archived barcode, inactive variant or item, another organization's code200 with the variant and quantity policy; data: null for every other casetest/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 outletdata: null; the same plain 403 for existing and unknown outlets; 404 · organization.structure_not_found only for an organization-wide holdertest/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 cashier200 with item and variant; data: null; 403 · catalog.action_forbiddentest/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 parameter400 · foundation.validation_failedtest/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 reactivateddata: null like an unknown code, as the till lists leave it out; resolved againtest/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 casesthe same verdict for listed, delisted, inactive variant, satisfiable and unsatisfiable required group, inactive group, option, target variant and target item, and the contract agreestest/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, resolveddata: null for the unconfigurable variant only, the other variants of the item still scan, as the lists and the contract agreetest/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 outlet409 · catalog.barcode_ambiguoussrc/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 office409 · catalog.barcode_ambiguous, ordered and bounded like the tillsrc/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 statements7, guards, BEGIN and COMMIT includedtest/e2e/statement-budget.e2e-spec.ts › $route issues exactly $statements SQL statements
A retail journey: matrix, barcodes, scan, edit, deactivationthe till answers null for the deactivated variant while the back office still finds ittest/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.

CaseExpected answerProving 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 keytest/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 maxQuantitytest/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 reachtest/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 onename order, active variants with the quantity policy, categoryId, no codes, SKUs or prices; meta.catalogVersion equals the stored versiontest/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 itemthe first two absent; the item stays with its other variantstest/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 categorythe branch leaves the tree, its descendants keep their status, its items sell with categoryId: null; reactivating the ancestor brings the branch backtest/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 optionsonly 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 inactivethe 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 aliketest/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 outletsno row and all sell everything but exclusions; selected only listings; none and an inactive unit nothingtest/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 organization304, 304; 200 with a new tag and the next catalog version; still 304test/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 items2,000 items and meta.truncated: true; the search pages through all 2,001 under one catalog versiontest/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 rolethe same plain 403 at an existing, unknown, closed and foreign outlet; nothing recordedtest/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 outlet200 there; the same plain 403 at another outlet of the same unit, an unknown, a closed and a foreign outlettest/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 holder404 · organization.structure_not_foundtest/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 plan200 for the owner and a cashiertest/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 today200 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 403test/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 renamed200 with a new tag to the previous If-None-Match, the catalog version unchangedtest/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 movedtest/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 unitthe plain 403 on the read and on the search; never the items of the new unittest/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 scantest/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 tagthe plain 403, never 304, at another outlet and at an unknown onetest/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 statements8 for 200 when no listed item or variant has an image (9 with images, AC4), 7 for 304, guards, BEGIN and COMMIT includedtest/e2e/statement-budget.e2e-spec.ts › $route issues exactly $statements SQL statements
If-None-Match compares weaklythe strong tag, its weak form, a list entry and * matchsrc/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 journeymodifiers and defaults at the waiter's till; a unit manager's delisting removes the dish; a deactivated category leaves the tree while its dish sellstest/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 variantonly that variant leaves variants[], the item stays while another variant is configurable; the item-level modifierGroups are unchangedtest/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 resumedevery variant that inherits it leaves the lists, a variant whose active offer replaces it stays; all come back on resumetest/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 pausedthe 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 modifierGroupstest/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 onetest/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 200test/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 usestest/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 ordermenus 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.viewtest/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 configurablenone of them; a section left without entries and without non-empty subsections, and a menu left without sections, are droppedsrc/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 answersrc/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 C19src/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 M2btest/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 sellssrc/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 idsrc/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 menustest/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 304test/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 activeno 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 entrysrc/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 acceptedsrc/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 keytest/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 componentstest/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 bundleGroupstest/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 choicethe till shows exactly the choices admit accepts and hides every one it refusestest/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 hiddentest/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_deeptest/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 carrysrc/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 itemsrc/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 categoriestest/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 nottest/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 categorytest/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 categorytest/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 senttest/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 304test/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 categorytest/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 itemssrc/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 keystest/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.

CaseExpected answerProving test
Prefix of name, code or SKU, any case or accent; %, _ and a backslash; a delisted or inactive itemfound; literal; an empty page exactly like an unknown nametest/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 variantthe till finds the item only by the active one; the back-office list by all threetest/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, barcode400 · collection.cursor_tampered; 400 · collection.cursor_incompatible; 400 · foundation.validation_failedtest/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 readthe 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 changedtest/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 backno gap, no duplicate; meta.catalogVersion constant on a stable catalog and newer after a writetest/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 nothingempty pages, with and without qtest/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 statements8 when no item of the page has an image (9 with images, AC4), guards, BEGIN and COMMIT includedtest/e2e/statement-budget.e2e-spec.ts › $route issues exactly $statements SQL statements
A retail journeythe scan resolves the exact variant and a few letters find the same shirttest/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 configurabletest/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 C17test/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 readtest/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 textsrc/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.

CaseExpected answerProving 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 writtentest/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 itemtest/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 ittest/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 ittest/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 defaulttest/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 nonetest/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 again200, 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-Replayedtest/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-Match412 · foundation.precondition_failed, nothing written; the current tag is acceptedtest/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 catalog200 with the new mode and a new tag; 304 while only the other organization writestest/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 reactivated200 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 readno row and an active all row read all, an active selected row selected, an inactive row none; none keeps the mode it hadsrc/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 planthe mode change 403 · platform.capability_not_entitled; reading and delisting still 200test/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 unitthe 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_unavailabletest/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 }).

CaseExpected answerProving test
List and delist in selected mode200 with listedCount; one record per call naming the items that changed; a repeat records nothingtest/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 backan exclusion row, then lifted; the item status and every other unit untouched; an archived item may be delistedtest/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 writtenmissing rows inserted, others change only their status; nothing when every item is already in the requested statesrc/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 call200, one record naming all of themtest/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 mode400 · 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 routes200; one items_listed and one items_delisted record, each only when its direction changed; 404 for .../listings and .../delistingstest/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 changethe whole change refused, nothing written; the refusal recorded under items_listed when list names an item, else items_delistedsrc/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 none422 · catalog.item_archived; 422 · catalog.assortment_disabled; nothing written; the listings come back with the modetest/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 organization400; the same 404 · catalog.not_found as an unknown item, the whole call refusedtest/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 unitdata: null at that unit's outlet while another unit's outlet resolves; nothing in none; everything in all; another unit unaffectedtest/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.

CaseExpected answerProving test
listed=true and listed=false in all, selected and none mode, pagedthe 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_listedthe 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 restsrc/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 requestthe closed choice true or false; anything else, or listed without a unit, 400src/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 unit400; 200 at its unit, 403 at anothertest/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
listedCountexactly what catalog.listed_items admits: every active item without a row, only listings in selected, nothing when offsrc/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 curatora manager of one unit and a reader of the whole catalog get []; an organization-wide holder of catalog.assortment.manage gets units_with_different_verticalstest/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 curateto the owner and to a manager of that unit on the current plan; not to a mere viewer nor on a lapsed plan; nothing recordedtest/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 verticalswarnings: ["units_with_different_verticals"]; none for the same verticals or once the other unit is inactivetest/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/).

CaseExpected answerProving test
A sellable variant at its unitsellable: 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 answerstest/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 unitunknown with no snapshot, identical for all three; business_unit_unavailable for all four units, the variant's own snapshot kepttest/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 groupitem_archived, item_inactive, variant_archived, variant_inactive, assortment_none, not_listed, not_listed, required_modifier_unsatisfiable, the first failing fact in that ordertest/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 scanthe 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 includedsrc/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 linesevery 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 selectionstest/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 maximumCatalogNotSellableError 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 admittedtest/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 lineCatalogSellableRequestInvalidError (empty, too_many, duplicate, too_many_options) before any SQL or locksrc/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 2admitted; selection_above_maximum; selection_above_maximum, as the Sales schema sums quantitysrc/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_minimumsrc/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 unitssrc/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 SQLsrc/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 themtest/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 unittest/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 namedsrc/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 addedtest/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 admittedtest/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 aresrc/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_consistencytest/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 oneresolve 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 idtest/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 offertest/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 searchtest/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 completedleft 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 boundstest/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 levelthe 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 boundstest/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 breakfastthe 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 statisticstest/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 againthe 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 unittest/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 timesadmitted, and the rows Sales would store pass its real trigger and group check; selection_below_minimum; option_quantity_above_maximum and selection_above_maximumtest/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 transactionresolve 1; admit 3: the version row, the unit row, the readtest/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 refusingnothing written, the catalog version unchanged, no audit recordtest/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 modulemay 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 R1src/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 texttest/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 texttext_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 firsttest/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 lineCatalogSellableRequestInvalidError with too_many_texts before any locktest/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 optionlisted on the till and sellable; admission without a selection is selection_below_minimum, with the option but no value text_required, with the value admittedtest/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 prefixthe same fields under the same rule; an option with defaults carries no presentation keytest/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 selectionsoptional text, optional presentation fields and four new refusal reasons, with the ten-text limit publishedsrc/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 ​

InvariantExpected answerProving test
Every catalog command locks the catalog version row of its organization before any item rowthe lock order of §5src/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 raceboth apply one after the other, no deadlock, no 500test/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 independentcatalog_items_creation_idempotency_uqdatabase/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 planthe same status and body as any plain denialtest/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 nothingthe lock order of §5src/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 togetherone 200, one 422 · catalog.category_cycle, the tree stays acyclictest/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 togetherall applied one after the other, no deadlock, no 500test/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 variantwritten with the item in one transaction; the deferred trigger is the backstoptest/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 lockthe lock order of §5src/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 rowthe retirement and the write are scoped to item_variant_id IS NULL (K9b), so such a row survives a PUT unchangedsrc/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 columnitem_id, organization_id, catalog_id, created_at, modifier_group_id, option_kind, item_variant_id stay out of every SETsrc/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 codeone 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 500test/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 togetherapplied one after the other; a set that read an older version is refused whole with 412; the transition, which carries no If-Match, always appliestest/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-Matchthe six 200; the duel one 200 and one 412 · foundation.precondition_failed, and the item ends with the winner's list, wholetest/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 roundsboth serialise on the catalog version row; the offer answers 200 or 422 · catalog.default_options_invalid; no item is left preselecting the inactive optiontest/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 defaultscomposite 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_idxno 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_defaultdatabase/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 itemapplied one after the other, no deadlock, exactly one default at committest/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 itemsone 201, one 409 · catalog.barcode_takentest/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 routethe 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 writtentest/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 unit200 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 recordtest/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 unitthe same plain 403 as at an active or unknown unit, on every route; nothing writtentest/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; OD3any unit 200; only they learn 404 · organization.structure_not_found; the assortment holder cannot edit or create itemstest/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 organizationits own tenant: 404 like any unknown unit; the victim's tenant: 403 · tenant.context_rejectedtest/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 tillits own tenant: 404 like an unknown outlet; the victim's tenant: 403 · tenant.context_rejectedtest/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 tillseach search finds only its own item and answers its own catalog versiontest/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 orderthe lock order of §5; delisting never asks about the plansrc/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 columnonly assortment_mode and statussrc/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 unitsall 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 listingstest/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 nothingthe lock order of §5: catalog commands of the organization (another unit's included) wait until the sale's transaction ends, then applytest/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 heldboth wait: every bumped catalog write updates the shared version row, and the unit row is sharedtest/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 rowit waits, then judges what the command committed (item_inactive), never a half statetest/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 unitneither waits for the othertest/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 roundsno deadlock, no unexpected error: each admission admitted or refused, each command 200test/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 contractthe same answer as an unknown one, under the caller's RLS context onlytest/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 outletsees no assignment, deletes none, cannot write one for itself nor link a menu and an outlet of two organizationsdatabase/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 outletsees none, deletes none, cannot hide an item of its own at this outlet nor an item of this one at its owndatabase/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 ​

CaseExpected answerProving test
Set two images, read them back, replace them, repeat the same list200 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 aftertest/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 item422 · 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 unchangedtest/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 removes403 · platform.capability_not_entitled; 200test/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 attachedthe attach waits for the archive and answers 422 with reason archived, nothing attachedtest/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 positionno violation of item_assets_one_item_primary_uq: the old primary is demoted firstdatabase/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 otherwisebetween one and four bumps per command, zero for the same listdatabase/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 budget17 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 list400 · foundation.validation_failed at the request schema; the empty list is accepted and removes every imagesrc/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 schemarefused before any transaction with catalog refusal image_list_invalid, answered 400 · foundation.validation_failed; ten distinct images passsrc/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 refusal404 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 metasrc/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 organizationnothing 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 notINSERT, UPDATE, upsert and DELETE each move the version once; a no-op UPDATE, upsert or DELETE moves it zero timesdatabase/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.

CaseExpected answerProving test
Set the images of two variants and of the item in one requesteach 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 ordertest/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 primaryeach variant keeps one primary, the item list is untouchedsrc/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 lista second variant primary violates item_assets_one_variant_primary_uq; replacing the item list keeps the variant rowsdatabase/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 variant404 · catalog.not_found; 404; 422 · catalog.variant_archivedtest/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 sentkeeps its images; 200 with the same tag, nothing written or recordedtest/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 list400 · foundation.validation_failed at variants[i].variantId or variants, and image_list_invalid at the use case boundarytest/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 images200 without asking the plansrc/modules/catalog/application/items/set-item-images.use-case.spec.ts › removes variant images on a lapsed plan without asking the plan
Statement budget15 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 ​

CaseExpected answerProving test
The item and each variant answer their imagesimages[] and variants[].images[] with assetId, isPrimary, sortOrder, status, thumbnailUrl, cardUrl, detailUrlsrc/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 imagesthe old If-None-Match answers 200 after an image change, the new one 304; a stale If-Match answers 412test/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 exportsCatalogModule imports AssetsModule; ItemRepository resolves with AssetDeliveryContract and reads the URLs through itsrc/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 answersone 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 statementsrc/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 OFFSETsrc/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 oncevariants 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 itemsrc/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 reada 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 tagsrc/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 statisticsthe 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 rowssrc/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.

CaseExpected answerProving test
An item with a primary image and one without; the image set; another organization sets an imagethe 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 appearstest/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 onethe variant answers its own imageRef and aiGenerated (true for an AI image); the other carries no image keys and shows the item imagetest/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 changesa new tag (the delivery fingerprint), the old If-None-Match answers 200 with the new template, the new tag 304test/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 archivedimageRef: 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-Matchno describe call; the 304 is decided before any item or image is readsrc/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 pageseach page item with its imageRef, the template in meta.media, one describe per pagetest/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 imagesC17 200 9, 304 7, C19 9test/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 archiveda 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 primary23505 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 pagessrc/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 itemno keys; null and false; its reference; an item always both keyssrc/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.mediathe new keys validate, a missing imageRef or a non-boolean aiGenerated is refused; tileUrlTemplate a string or null, never absentsrc/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 itemsempty 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 onlysrc/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 ​

CaseExpected answerProving test
Five items set twice200 with every item and the tag GET answers, in request order; the version moves once, then at most four times; one record per changed itemtest/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 item422 · catalog.image_not_attachable naming each item and asset, 404 · catalog.not_found and 422 · catalog.item_archived naming each item; nothing writtentest/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 planremoval only 200; an addition 403 · platform.capability_not_entitledsrc/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 budget15 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 list400 · foundation.validation_failed at the schema, and the same refusal at the use case boundary before any transactionsrc/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 ifMatch200; 412 · foundation.precondition_failed naming items.<itemId> with precondition_failed, nothing written, the version unchanged; 400 at items[0].ifMatchtest/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 tag404 first; 412 before 422src/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 ​

CaseExpected answerProving test
Make an item a combo with three groups (Principal, Bebida, Acompañamiento), read it back, revalidate200 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 recordtest/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 againevery 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 grouptest/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 codeonly 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 codesrc/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 askedtest/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 elseone 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 driftsrc/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 one412 · foundation.precondition_failed before anything is judged or written; 200test/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 itself200; 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 codetest/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 since422 · catalog.bundle_component_unavailable naming the field; the kept one staystest/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 groupisAvailable: 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 judgestest/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 held404 · 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 winssrc/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 groups404; 403 · catalog.action_forbidden; 404 at bundleGroups[0].choices[0].variantId; 400 at bundleGroups[0].minSelections; 400 at bundleGroupstest/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 lookup400 · 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 acceptedsrc/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 planan empty list 200; any other change 403 · platform.capability_not_entitled, nothing writtensrc/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 combo404 · catalog.not_found; 422 · catalog.item_archivedsrc/modules/catalog/application/bundles/set-bundle-groups.use-case.spec.ts › answers not found for an unknown item and refuses an archived one
Readany item, archived included, after catalog.items.view; a plain item answers []; retired groups and choices are not shown; nothing locked or recorded; 403 before readingsrc/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 budgetPUT 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 7test/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 triggersSELECT, 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 bumpdatabase/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 tenantsees 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 group400 · 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 againsrc/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 variant404 · catalog.not_found or 422 · catalog.bundle_choice_mismatch, never the stored compositionsrc/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 HTTPa new group 403 · platform.capability_not_entitled; an empty list 200test/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 guarda 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 agreesdatabase/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 readruns against PostgreSQL and answers what the plain read answers, retired rows included; an unknown item answers nothingsrc/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 included200, the same body and tag, no record and no catalog version changetest/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 PUTsuspended: 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 senttest/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 fieldsnever reach the answer: a group and a choice carry exactly their documented keyssrc/modules/catalog/presentation/dtos/bundles/bundle-groups-response.spec.ts › answers exactly the documented fields of a choice, whatever else the application carries
Write failuresthe cycle guard, the code index and the two variant foreign keys map to typed refusals keeping the cause; any other failure passes throughsrc/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 §20).

CaseExpected answerProving test
A combo with a chosen burger (with its cooking option), fries twice and the drink group left outadmitted; 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 drinktest/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 maximumtwo occurrences, as two Sales rows; bundle_selection_below_minimum or bundle_selection_above_maximum on the sumsrc/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 outthe choice sent is admitted and the available default is answered in removedDefaults; a default no longer available is never namedsrc/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 defaultbundle_selection_below_minimum for that group at lines[0].bundleSelections, with bundleGroupId and a detailsrc/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 outletIdthe 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 senttest/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 maximumnot_listed and selection_below_minimum (with modifierGroupId) at the path of the choice, bundle_choice_not_offered, bundle_selection_above_maximum; nothing admittedtest/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 itembundle_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 outbundle_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 onetest/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_countbundle_choice_above_maximum at its first entry, the entries summedsrc/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 combothe inner combo judged with its own groups and defaults, answered under its choice; the sales schema stores the nested rowstest/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 3request problem too_deep before any lock; bundle_too_deep at the list, at most one read per allowed level; admittedsrc/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 componentsinvalid_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 expandingsrc/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 statementsrc/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 ownsread 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 unchangedsrc/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 surfaceevery type of the tree and of BundleAdmissionRefusal is exported by the contractsrc/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 combothe PUT waits for the admitting transaction (one catalog version share lock), then appliestest/e2e/catalog-sellable-bundles.e2e-spec.ts › makes a composition change of the combo wait until the admitting transaction ends, then applies it
Statement budget6 statements for a plain line, 7 for a combo with three groups, 8 for a combo inside a combo: one per level of componentstest/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

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 ​

CaseExpected answerProving test
Create a draft menu, build Entradas with Frías, Principales (two selected sizes) and Postres, read it back, revalidate201 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 recordtest/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 againevery 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 idtest/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 idsa 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 menusrc/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 selectionstop sections, subsections under their parent and entries by position; selected variants in the order sent, none for allsrc/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 has200, 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 changetest/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 elseone 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 keysrc/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 one412 · foundation.precondition_failed before anything is judged or written; 200test/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 one422 · 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 activationtest/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 permission200, never asks the plan; 200 unchanged, no record; 403 · catalog.action_forbiddensrc/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 activetest/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 repeattest/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 includedthe seed of 0380_audit.sql holds them all; no two transitions share an actionsrc/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 since422 · catalog.menu_item_unavailable naming the field; the kept one staystest/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 oneisAvailable: false and availableEntryCount without it; every entry under a suspended section, its subsections included, unavailable; available while one offered variant is activetest/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 held404 · 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 winssrc/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 code400 · foundation.validation_failed with meta.errors[] at the alias (sections[1].code, or .sectionId), before any lookup; never reaches the upsertssrc/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 idrefused before the tree is compared or any item is readsrc/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 level404; 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].subsectionstest/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 lookup400 · 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 lookupsrc/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 plana structure with sections, a create and an activation 403 · platform.capability_not_entitled; emptying a draft menu and archiving 200test/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 menu404 · catalog.not_found; 422 · catalog.menu_archivedsrc/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 listany menu, archived ones included, after catalog.items.view; the list with counts by name; nothing locked or recorded; 403 before readingsrc/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 codes201 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 changedtest/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 counted422 · catalog.menu_limit_reached, nothing writtensrc/modules/catalog/application/menus/menu-commands.use-case.spec.ts › refuses a menu beyond the limit, archived ones included, and a lapsed plan
Statement budgetPUT .../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 10test/e2e/statement-budget.e2e-spec.ts › sets, reads and activates a menu in the measured statements, whatever the number of sections and entries
Selected variantswritten in order; switching the entry to all deletes them and lists every variant that is not archivedsrc/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 .../structuresuspended: 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 senttest/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 readanswers what the plain read answers; an unknown menu answers nothing; list and count agreesrc/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 vocabularySELECT, 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 kinddatabase/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 checksa 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 commitdatabase/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 tenantsees 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 organizationdatabase/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 fieldsnever reach the answer: an entry and a variant carry exactly their documented keyssrc/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 failuresthe 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 throughsrc/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 summarythe live sections with their names and the entries in tree order; null for an empty menusrc/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.

CaseExpected answerProving test
An outlet nobody assigned menus to; two menus set in order with the tag read; revalidation200 { 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 aftertest/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 menus200, nothing written, no record, the catalog version unchanged; a new order is a change: written and recorded oncetest/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 changeits tag moves (the catalog version moved); what it sells is the same: menus only organizetest/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 tag428 precondition.required; 412 foundation.precondition_failed, checked after the version lock and the read; nothing writtentest/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 menu404 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 writtentest/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 assignedstays listed with status archived and keeps its place when the list is sent again; unpublishing and archiving an assigned menu keep workingtest/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 writecatalog.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 answersrc/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 listnever gated by the plan and reads no menu; one record with the ids before and null aftersrc/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 onethe same plain 403 catalog.action_forbidden on both routes; the write refusals are audited permission_missing; nothing writtentest/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 outletreads and sets its menustest/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 organization404 organization.structure_not_found on both routes, empty list or nottest/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 operating422 authorization.outlet_not_operational, audited; the empty list still workstest/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 rules0 to 10 ids in order; an eleventh at menuIds; each repetition at its position; a missing list, a malformed id and any other field refusedsrc/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
StatementsGET 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 listtest/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
Repositoryan unknown outlet told apart; the order sent kept; one version move per statement; only the menus of the organization judgedsrc/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 tableone 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 changesdatabase/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.

CaseExpected answerProving test
An outlet that hides nothing; two items set with the tag read; revalidation200 { 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 idstest/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 set200, nothing written, no record, the catalog version unchanged; a different set is written and recorded oncetest/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 changesthe 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 againtest/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 tag428 precondition.required; 412 foundation.precondition_failed, checked after the version lock and the read; nothing writtentest/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 tagone 200 and one 412, and the stored set is the winner'stest/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 item404 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 writtentest/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 writestays 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 writecatalog.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 answersrc/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 setnever gated by the plan and reads no item; one record with the ids before and null aftersrc/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 onethe same plain 403 catalog.action_forbidden on both routes; the write refusals are audited permission_missing; nothing writtentest/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 outletreads 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 organization404 organization.structure_not_found on both routes, empty set or not; an admitted caller at an unknown outlet 404test/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 operating422 authorization.outlet_not_operational, audited; the empty set still workstest/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 outletsan item of another organization is 404 and nothing is hidden; the outlet of another organization is 404 for the readertest/e2e/catalog-outlet-hidden-items-access.e2e-spec.ts › never lets an organization hide, or read, what belongs to another one
Body rules0 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 statussrc/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 meanscompared as a set, not as an order; only the items that start being hidden are judgedsrc/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 unitthe 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 againsrc/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 listnever sold at any outlet, hidden or not: an outlet can only hidesrc/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 toothe 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 itsrc/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 itemleaves the menus of that outlet only; the sibling keeps itsrc/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 hidesincomplete at that outlet, in every read and in the admission (not_listed at the choice), complete at the sibling and again when shownsrc/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 shownavailable at every outlet of the unit: in the till list and its group options, the search, resolve and admit, with an outlet and without onetest/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 againthe 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 ittest/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 targetthe same answers as a delisted targettest/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 againthe 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 optiontest/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 composedthe 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 delistsincomplete 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 scopetest/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 composedthe option is unavailable too: an option sells a plain itemtest/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 themthe 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 activeOptionsrc/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 variant422 · catalog.option_target_not_plain, nothing written; 201 on the plain varianttest/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 one422 · catalog.option_target_not_plain at options[i].variantId, nothing written; 200 on the plain varianttest/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 onesthe target is refused as not plain once it is known and active, in the create and in the set, before any writesrc/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 combosthe 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 plainsrc/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 atsrc/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 itemall four agree: not listed, not found, not scanned, not_listed at lines[0]; the sibling outlet sells it in all fourtest/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 fragmentthe 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 tablesrc/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 contracttest/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 unitfalse 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 outletdatabase/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 twicethe 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 oncetest/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 permission400 foundation.validation_failed on both routes, before any permission is decided; the same member gets 403 catalog.action_forbidden for a well-formed idtest/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 documentthe 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 headersrc/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 organizationthe 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
StatementsGET 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 ordertest/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 movedthe plain 403 catalog.action_forbidden; nothing written, no success record, no catalog version movetest/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 lockthe second decision refuses with the plain 403; nothing writtentest/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 itit waits for the write to commit; the write is answered 200 for the unit it started with and the outlet moves afterwardstest/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 itall finish, no deadlock; the first 200, the second the plain 403test/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 timeout503 service.unavailable as Problem Details, nothing writtentest/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 lockexactly one denial audit record, in the transaction of the refusal, and nothing else writtentest/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 itemsone read-only snapshot, never a plain transactiontest/e2e/catalog-outlet-write-lock-order.e2e-spec.ts › reads the menus and the hidden items of an outlet in one read-only snapshot
Repositoryan unknown outlet told apart; one version move per statement; only the items of the organization judged; another organization sees no hidden itemsrc/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 functionone 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 listdatabase/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

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 ​

CaseExpected answerProving test
Windows set with the tag read, read back, revalidated200 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 midnighttest/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 changesthe 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 movedtest/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 order200, nothing written, no record, the catalog version unchangedtest/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 outletevery menu is always shown and the stored windows are replaced; the windows of a menu that leaves do not outlive ittest/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 tag428 precondition.required; 412 foundation.precondition_failed; nothing writtentest/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 list422 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 writtentest/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 hoursacceptedsrc/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 days14 per menu, the first one over it refused at its position; a window that crosses midnight is taken; a window without days opens every weekdaysrc/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 organization404 catalog.not_found, nothing writtentest/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 outletthe same plain 403 catalog.action_forbidden, audited; 403 platform.capability_not_entitled; 422 authorization.outlet_not_operational; clearing still workstest/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 menubefore 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 eachtest/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 opena 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 soldtest/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 menuno change is named at the instant one ends and the next startssrc/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 setthe 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 tagtest/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 readthe 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 agreesrc/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 zonethe 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 movestest/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 window05: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 instantsrc/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 changeinside it before the clock goes back the clock change is named (06:00:00Z), then its end at 07:00:00Z and its next startsrc/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 changethe clock change itself, then the exact boundary once the clock went backsrc/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 datethe boundary of the wall clock, no capsrc/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 soldthe same items are sold at every hourtest/e2e/catalog-till-menu-windows.e2e-spec.ts › never limits what the till sells: the same items are sold at every hour
SQL tablesa 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 zonedatabase/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
Planevery probe of the schedule is a keyed lookup on the primary keys and no window table is read wholesrc/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.

CaseExpected answerProving test
A catalog that never had a matrix200 with an empty list, Cache-Control: private, no-cache and a strong tagtest/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 valueeach active dimension once, with its active values in the order they were first sent and the number of active items that use ittest/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 tagthe count drops, the tag changes, the old If-None-Match answers 200; the same tag answers 304 with no bodytest/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 organizationit sees only its own dimensionstest/e2e/catalog-variant-dimensions-read.e2e-spec.ts › shows an organization only its own dimensions
A cashier403 · catalog.action_forbidden, the plain denialtest/e2e/catalog-variant-dimensions-read.e2e-spec.ts › refuses a member without catalog.items.view with the plain denial
The statementbinds 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 itemsrc/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 planon 2,001 linked items among 20 other tenants with 3,000 dimensions, no table is scanned whole and the cost stays inside its measured boundsrc/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 answercodes and names only, in a strict schemasrc/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.

CaseExpected answerProving test
A brand created once per key, read, read again with its tag201 then replay; strong tag the read repeats and 304 on revalidationtest/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 body409/400, nothing writtentest/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 name200 and one audit record; the same name writes nothing; 412; 409test/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 brand200 per step, a step in effect answers 200; rename refusedtest/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 organizationordered by lowercase name; another organization sees nonetest/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 nameone 201, one 409test/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 manager403, the plain denial; nothing writtentest/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 brand201 with productBrand { id, name }; replay; the new name and a new tag after the rename; a different brand under the key 409test/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 null200 each; the same brand writes nothing; productBrand absent after nulltest/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 id404 · catalog.not_found; 422 · catalog.product_brand_archived; 400; nothing writtentest/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 itthe item keeps it, is listed under it and can still be editedtest/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, pagedonly 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 versionone 200, one 412test/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 nameone 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 itthe 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 havetest/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 versionone 200, one 412test/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 versionone 200, one 412test/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.

CaseExpected answerProving test
An item created whose default variant holds identifiers; the same request again; another set under the key201 with identifiers in the order of the vocabulary; the replay answers the same item; another set 409test/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 holds201, 200, 200; the same set is already in effect (same tag); identifiers is absent once cleared; the audit holds added, updated and updatedtest/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 anyboth accepted (no uniqueness across variants); the read shows no identifiers key on a variant without themtest/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 set400 · foundation.validation_failed, nothing writtentest/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 versionone 200, one 412test/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 setordered by the vocabulary, equal sets compared by pair, one value per schemesrc/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 schemascheme, trimmed value of 1 to 100 characters, no repeated schemesrc/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.

CaseExpected answerProving test
An item created over HTTPevery version the create produced is announced, in publication order, with an item event naming the item and a variant event naming its default varianttest/e2e/catalog-change-feed.e2e-spec.ts › announces every version a create produced, with the item and its variant
An item archived, then restoredthe archive writes a retired item event with status archived and retiredAt; the restoration writes an upserted event with no retiredAttest/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 outoffer events name the new offers; the group left out writes one retired offer event for its offer idtest/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 optionsevery version is announced by catalog.modifier.changed events that name the grouptest/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 keyIdempotent-Replayed: true, no event, no outbox row and no version move; the first answer announced the item oncetest/e2e/catalog-change-feed.e2e-spec.ts › writes nothing for a replayed create and its first answer announced the item once
Four parallel createsfour 201; distinct increasing versions, every version announced, contiguous publication positions, one item event per itemtest/e2e/catalog-change-feed.e2e-spec.ts › gives parallel commands distinct increasing versions and loses no event
A business unit registeredthe listing event of its default assortment is written when the unit commits, naming the unittest/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 writeno offer, audit record, event, outbox row or version move survives; the successful edit before it had announced its changestest/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 transactionan item event then a variant event, each at the version its statement produced, with the outbox holding one row per eventdatabase/tests/contracts/catalog-change-feed-contracts.ts › each event carries the catalog version its statement produced
Archiving an item; delisting itretired with status, retiredAt and the item id; delisting is an upserted event with status inactive and no tombstonedatabase/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 listingan item event; a modifier group event; an offer tombstone; a barcode tombstone; a removed listing event with its scopedatabase/tests/contracts/catalog-change-feed-contracts.ts › deleting a listing writes a removed event with its scope
One statement over two categoriestwo events at the same versiondatabase/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 constraintsthe first moves the version and writes no event; the second still writes its eventdatabase/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 organizationno event, no outbox row, no version move; the other organization's feed is untoucheddatabase/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 keyrefused with 23503 or, for the replay with another payload, refused; the same payload writes one eventdatabase/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 tablesrefused on every insert and read, and on events.append_domain_eventdatabase/tests/contracts/catalog-change-feed-contracts.ts › the runtime is refused:
Every table that bumps the version; the tables that do noteach 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 tablesdatabase/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 rowdistinct increasing versions, events in the same order, the second waits for the firstdatabase/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 eventa 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 upsertsrc/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.

CaseExpected answerProving test
An outlet with items that have SKUs and barcodes, one archived item and one archived barcode, walked in pages of twothe 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 cuttest/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 twicecut, catalogVersion and the unit of the outlet with private, no-cache; the repeated page is byte-identicaltest/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 sellthe online scan at the outlet resolves the same variant; the index holds nothing the online scan answers null fortest/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 othereach outlet bootstraps what its own unit sells; the crossed cut is 409 · catalog.bootstrap_cut_movedtest/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 outlet400, nothing readtest/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 cut400 for the cursor (it is bound to its cut); 409 · catalog.bootstrap_cut_moved for the earlier cuttest/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 pagethe 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 refusedsrc/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 outletthe same plain 403 on both routes; nothing is recordedtest/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 outletreads the outlet and its first page; the next page and a new opening are the plain 403test/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 outlet404 · organization.structure_not_foundtest/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 againthe 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 versiontest/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 oncethe same body; no row, event, outbox row or version movetest/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 cut7, 8, 8 and 7test/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 activeonly the active barcodes; neither the archived item nor the inactive variantsrc/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 importevery variant and barcode read by its key; no sequential scan; rows and buffers within the ceiling per itemsrc/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 permissionthe cut is refused before any item is read; the permission is decided firstsrc/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 valueone token per catalog version, structure version and unit, parsed back, malformed tokens refusedsrc/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.

CaseExpected answerProving 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 seedsthe bootstrap plus the feed events after its cut equals the fresh bootstrap, and every item that differs was announced by an event of the feedtest/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 bootstrapthe device removes the item, the item and the variant with only the tombstones and the ids it holds, and agrees with a fresh bootstraptest/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 feedno event at or below it is missing from the data; every later event is above ittest/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 ​

CaseExpected answerProving test
Each of the seven replace-the-collection PUTs without If-Match, with *, with a stale tag, with the current tag, then the same list with its new tag428 · 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 recordtest/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 PUTs without If-Match: no session; another organization; an invalid body401 · identity.session_required; 403 · tenant.context_rejected; 428 · precondition.required, never 400test/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 contracta missing, blank or * header refused before the handler, a named tag let through; the header documented required: true with the 412 and the 428src/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 ​

CaseExpected answerProving test
Create, read, a replacement PUT, the item list, the tree, the group list, the workspace and the till readeach real body passes the schema the route documents, meta includedtest/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 keyrefused by every documented schematest/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 composersa single resource without meta documents a required empty object; declared keys close the object; a collection closes it to pageInfo and its keyssrc/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 ​

CaseExpected answerProving test
A member past the limit; its reads; another member of the organization429 · 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 201test/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 permissionthe first ones 403 with one denied record each, the rest 429 recording nothingtest/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 guardcounts per membership, never a read, falls back to the address without a tenant contextsrc/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 ​

CaseExpected answerProving 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 JSONaccepted by the schema, at most the route's ceiling and above the step below itsrc/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 listingsaccepted by the schema and under the 100 KB defaultsrc/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 editread 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 credentialswith 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 guardtest/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 parsersone JSON parser per ceiling for its method and path, JSON only, then the 100 KB default; mounted under the versioned prefix at bootstrapsrc/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 ​

CaseExpected answerProving test
51 top sections; 51 subsections in one sectiontoo_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 401X-Content-Type-Options: nosniff and no X-Powered-By on bothtest/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).

Application Foundation in progress. Tracked in issue #13.