Skip to content

Catalog Use Cases ​

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

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

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


1. Purpose and scope ​

1.1 What the Catalog is for ​

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

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

1.2 In scope for the first delivery ​

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

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

1.3 Explicitly out of scope, with the reason ​

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

2. Ownership and boundaries ​

2.1 Facts the Catalog owns ​

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

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

2.2 Neighbours ​

NeighbourDirectionWhat crosses the boundaryMechanism
OrganizationCatalog readsBusiness unit exists and is active; the effective business unit of an outlet on the site's date (organization.effective_business_unit, granted to runtime in runtime_grants.sql)SQL read inside catalog repositories under RLS, the same way organization already reads platform.business_unit_verticals directly (O11 "As built"). Module-boundary rules constrain TypeScript imports, not SQL (O11)
IdentityCatalog consumesPermission decision at an anchorPermissionCheckContract.decide with anchor (src/modules/identity/contracts/permission-check.contract.ts)
PlatformCatalog consumesPermission + capability + place operability decisionOperationAuthorizationContract (platform.authorize_operation, 0071; platform.authorize_operation_at, 0073:162)
AuditCatalog consumesTenant audit record in the same transactionTenantAuditContract
Pricing (future)Reads CatalogSellable variant snapshot; validity of variant / modifier option idsNew CatalogSellableContract (C20)
Sales (future)Reads CatalogAdmission of a variant for a business unit at sale time, atomicallyCatalogSellableContract.admit inside the shared tenant transaction (D18)
Inventory, Tax, Kitchen, Commerce, Production (future)Reference catalog.item_variants by FKVariant identity onlyFKs already in canonical SQL (§2.4)
Session context (Identity)Context for the clientworkspace.businessUnits with their verticals, outletsRead only. "La lista workspace.businessUnits es contexto, no autoridad" (APP_SHELL_AND_ONBOARDING.md line 60): every catalog command re-checks permission

2.3 Module contracts (R1 to R9) ​

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

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

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

2.4 Schema boundary and existing cross-schema SQL ​

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

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

3. Table traceability ​

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

3.1 Existing tables ​

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

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

TableWriterReaderInvariantLifecycle
catalogsensure_default_catalog (lazy, on first item/category/modifier write)every catalog readone default per organization (catalogs_one_default_uq); access_mode/is_default immutable (protect_catalog_identity, 4269)active forever in v1
itemsC4, C5, C7C1–C3, C17, C19, C20code unique per (organization, catalog), case-insensitive (redefined at 4426)active ⇄ inactive; → archived ⇄ restored (§4.4)
item_variantsC4, C6C3, C17–C20, all downstream FKsexactly one default per item (241 + deferred trigger 2979/2986); SKU unique per catalog (4430)active ⇄ inactive; archived final for named variants
item_variant_barcodesC11C18, C3value unique per catalog (4431); one active primary per variant (332)active ⇄ inactive; never deleted (a barcode may already be printed on a receipt)
categoriesC9C1, C2, C10, C17no cycle (advisory lock + trigger, 3019); code unique per catalogactive ⇄ inactive; archived
item_categoriesC8C2, C3, C17one primary per item (471)replaced as a set
modifier_groups, modifier_optionsC12C3, C13, C17, Pricing latercatalog_variant options point to a concrete variant (modifier_options_kind_target_chk)active ⇄ inactive; archived
item_modifier_groupsC13C3, C17per-item min/max; unique per item scope (2013), every status included; status active, inactive or retired since revision 94, every read ignoring retired (CATALOG_OPTIONS_V2.md §4.1, K9a)written in place as an ordered set since K9b: a kept or re-offered link keeps its id, a removed one is retired, never deleted; the runtime holds no DELETE on it since revision 101 (K9c)
business_unit_catalogs, business_unit_itemsC14, C15C16–C20ownership columns immutable; BU row locked on each write (4460)active ⇄ inactive; never deleted

3.3 Gaps: needed facts with no table ​

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

No other new table is proposed.


4. Domain model and vocabulary ​

4.1 Aggregates ​

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

4.2 Identifiers and codes ​

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

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

4.3 Scoping rules ​

FactScopeGrounding
Item, variant, barcode, category, modifier groupCatalog namespace (v1: the organization's default catalog), i.e. organization-widecatalog_id NOT NULL on every master row; ARCHITECTURE.md §14: business units share the "catálogo base"
Whether a business unit sells an itemBusiness unitbusiness_unit_catalogs, business_unit_items; APP_SHELL_AND_ONBOARDING.md line 34: "El catálogo compartido respeta asignación y surtido"
What an outlet's POS showsOutlet → its effective business unit todayorganization.effective_business_unit(org, outlet, site date) (the same resolution platform.authorize_operation uses)
Prices per brand/outlet/menuNot catalogpricing.price_book_assignments
Legal entity, brand, siteNot a catalog scope in v1No catalog table carries them; Pricing and Sales freeze legal entity (D04)

4.4 Lifecycle states ​

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

StateMeaningVisible in POS readVisible in back-office list
activeSellable where listedyesyes (default filter)
inactiveTemporarily withdrawn everywhere (seasonal dish, paused product)noyes, with filter status=inactive
archivedRetired; kept because downstream history references the variantnoonly with status=archived
text
             deactivate                 archive
  active ─────────────────▶ inactive ─────────────▶ archived
     ▲  ◀───────────────── │   ▲                       │
     │      reactivate     │   └────── restore ────────┘
     └────────── archive (direct) ─────────────────────▶
  • archive is allowed from active or inactive; restore returns an archived item to inactive (never straight to active), so a restored item is reviewed before it sells again. (Restorability itself is OD8.) Variants follow the same table since API v7 PR 2, and categories, modifier groups and modifier options since API v7 PR 3 (each with its own restore). The names in the diagram are the closed list a client sends to POST .../transitions (C7, C9, C12). deactivate and archive never need the plan; reactivate and restore do.
  • Nothing is ever hard-deleted by the runtime (no DELETE policy exists on master tables; the header says "archived/inactivated rather than hard-deleted", 0080_catalog.sql:60-62).
  • The default variant cannot be deactivated or archived while its item is active; to retire a variant that is default, first make another variant the default (assert_item_default_variant, 2900).
  • Menus have their own draft/active/archived (2368): created draft, activate (draft → active, gated) when at least one entry is available, unpublish (active → draft), archive (draft or active → archived) and, since API v7 PR 3, restore (archived → draft, gated, with its structure); archiving is no longer final (M1). Sections and entries are active, inactive (SQL only) or retired.

5. Use cases ​

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

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

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

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

Shared pathAnswer
No session401 · identity.session_required
Organization not selectable403 · tenant.context_rejected
Permission missing at the anchor403 · catalog.action_forbidden (new, audited as denied, like organization.action_forbidden)
Scope assigned to an inactive place403 · authorization.scope_unavailable
Capability not in the plan (gated commands only)403 · platform.capability_not_entitled
Business unit anchor inactive422 · authorization.place_not_operational
Outlet anchor not operating422 · authorization.outlet_not_operational
Malformed id, code or body400 · foundation.validation_failed with meta.errors
Unknown or foreign-tenant catalog object404 · catalog.not_found (new; RLS makes "another tenant's" and "does not exist" identical)
Unknown or foreign-tenant business unit / outlet in the path404 · organization.structure_not_found (reused, as session context does)
If-Match stale412 · foundation.precondition_failed, nothing written
Unexpected constraint not mapped422 · catalog.item_refused (new, generic backstop like organization.structure_refused)

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

5.1 Reads for the back office ​

C1 — Open the catalog workspace ​

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

C2 — List and search items ​

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

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

C3 — Read one item ​

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

5.2 Item commands ​

C4 — Create an item ​

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

C5 — Update an item's profile ​

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

C6 — Manage variants ​

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

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

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

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

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

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

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

C8 — Classify an item ​

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

5.3 Categories ​

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

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

C10 — Read the category tree ​

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

Category lifecycle and effective visibility ​

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

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

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

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

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

5.4 Barcodes (retail) ​

C11 — Set the barcodes of a variant ​

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

5.5 Modifiers (restaurant first, vertical-neutral) ​

C12 — Define a modifier group and its options ​

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

C13 — Offer modifier groups on an item ​

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

5.6 Business-unit assortment ​

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

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

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

C15 — List or delist items at a business unit ​

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

C16 — Read a business unit's assortment ​

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

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

C17 — Read what an outlet sells ​

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

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

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

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

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

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

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

C18 — Look up a barcode at an outlet ​

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

C19 — Search what an outlet sells ​

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

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

5.8 Internal contract ​

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

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

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

5.9 Status of the later cases ​

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

CaseStatusRoute or reference
C21 Menus and outlet menu assignmentBuilt: M1 structure ("M1 as built"), M2a assignment (C29, "M2a as built"), M2b till read by menuGET, POST /catalog/menus, GET, PATCH /catalog/menus/{id}, PUT .../structure, POST .../{activation,archival}; GET, PUT /catalog/outlets/{id}/menus; menus in the sellables read
C30 Items an outlet does not sellBuilt: K16 ("K16 as built")GET, PUT /catalog/outlets/{id}/hidden-items
C32 Variant dimensionsBuilt: K13 ("K13 as built"): the matrix writes them, the item read shows them, the list offers them as filtersGET /catalog/variant-dimensions; variantValue in GET /catalog/items
C22 Bundles / combosBuilt: B1 composition, B2 admission (C28), B2c bundleGroups in the till and the search; B3 waits for Pricing and SalesGET, PUT /catalog/items/{id}/bundle-groups
C26 ImagesBuiltPUT /catalog/items/{id}/images, PUT /catalog/item-images
S1 Menu time windowsBuilt: C31 ("S1 as built"); holidays and date ranges deferredwindows in PUT /catalog/outlets/{id}/menus; menusValidUntil in the sellables read
C23 TranslationsDeferred: content-locale negotiationPUT /catalog/items/{id}/translations/{locale} (design sketch)
C24 Food declarationsDeferred: catalog_food boundary decisionPUT /catalog/items/{id}/allergens (design sketch)
C25 Additional catalogsDeferred: OD4POST /catalog/catalogs, assign to business units (design sketch)
C27 ImportDeferred: its own designPOST /catalog/imports (design sketch)

5.10 Derived API ​

GroupRoutesCases
Workspace and readsGET /catalog/workspace, GET /catalog/items, GET /catalog/items/{id}, GET /catalog/categories, GET /catalog/categories/{id}C1, C2, C3, C9, C10, C16
ItemsPOST /catalog/items, PATCH /catalog/items/{id}, POST /catalog/items/{id}/{deactivation,reactivation,archival,restoration}, PUT /catalog/items/{id}/categories, PUT /catalog/items/{id}/modifier-groupsC4, C5, C7, C8, C13
Variants and barcodesPOST /catalog/items/{id}/variants, PATCH .../variants/{vid}, POST .../variants/{vid}/default-selection, POST .../variants/{vid}/{deactivation,reactivation,archival}, PUT .../variants/{vid}/barcodes (the set, API v7 PR 4)C6, C11
CategoriesPOST /catalog/categories, PATCH /catalog/categories/{id} (moves too since API v7 PR 3), POST /catalog/categories/{id}/transitionsC9
ModifiersPOST /catalog/modifier-groups, PATCH, options, lifecycleC12
ImagesPUT /catalog/items/{id}/images with images and optional variants, PUT /catalog/item-images with an optional ifMatch per entry (Assets slices A5b and A5c, ASSETS_USE_CASES.md §7.5; merged in API v7 PR 4)AC1, AC2, AC3, AC5
CombosGET /catalog/items/{id}/bundle-groups, PUT /catalog/items/{id}/bundle-groups (B1)C22
MenusGET /catalog/menus, POST /catalog/menus, GET /catalog/menus/{id}, PATCH /catalog/menus/{id}, PUT /catalog/menus/{id}/structure, POST /catalog/menus/{id}/{activation,archival} (M1)C21
AssortmentPUT /catalog/business-units/{id}/assortment, POST .../assortment/{listings,delistings}C14, C15
Outlet menusGET /catalog/outlets/{id}/menus, PUT /catalog/outlets/{id}/menus (M2a; the windows of S1)C29, C31
Outlet hidden itemsGET /catalog/outlets/{id}/hidden-items, PUT /catalog/outlets/{id}/hidden-items (K16)C30
POSGET /catalog/outlets/{id}/sellable-items, GET /catalog/outlets/{id}/barcode-matches, GET /catalog/outlets/{id}/sellable-item-searchC17, C18, C19
InternalCatalogSellableContractC20

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


6. Vertical variations ​

6.1 Common vs vertical-specific ​

ConceptRestaurantRetailHospitalityWhere it lives
Item + default variantDish, drinkProductService/extra (laundry, minibar)Catalog, common
Named variantsSizes (small/large), presentationsSize, colour, packRarelyCatalog, common
SKUOptionalUsuallyOptionalCatalog, common
BarcodesBottled drinks onlyCoreMinibarCatalog, common
Sales unit and stepunitunit, kg (step 0.050)unitCatalog, common (item_variants)
CategoriesStarters, mains, drinksDepartmentsService familiesCatalog, common
ModifiersCore (cooking point, extras, sides)Rare (gift wrap, engraving)Room-service dishesCatalog, common
Bundles/combosCombo mealsKitsPackagesCatalog later + Pricing
Menus, sections, daypartsCore laterNot neededRoom-service menuCatalog later (G1)
Course, prep station, routingYes——Kitchen (vertical)
Allergens, nutritionYesFood retailRoom servicecatalog_food (proposed)
Rooms, room types——YesHospitality (Phase 10), not catalog
Stock tracking of an itemOptionalYesMinibarInventory (catalog_stock_links)

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

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

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

6.2 How business-unit verticals apply ​

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

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

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


7. Authorization design ​

7.1 Permission codes ​

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

CodeGrantsAnchor checkedCapability gate
catalog.items.manageCreate and edit items, variants, barcodes, categories, modifier groups, combos and menus; lifecycle of all of themOrganization-wide only (v1; OD3)catalog.items for create/edit/reactivate; none for deactivate/archive
catalog.items.viewBack-office reads C1–C3, C10, C16, C21 (menus), C22 (combos)Organization-wide, or a business unit (with businessUnitId)none
catalog.assortment.manageC14, C15Business unit (authorize_operation_at('business_unit')); an organization-wide scope also satisfies itcatalog.items for listing and mode changes; none for delisting
catalog.sellables.viewC10, C17, C18, C19Outlet (PermissionCheckContract with outletId, which also accepts broader scopes covering that outlet)none

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

7.2 Seeded role mapping (proposal, per D30) ​

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

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

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

7.3 Capability gating ​

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

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

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


8. Performance and data design ​

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

ProfileItemsVariantsBarcodesCategoriesModifier groups / optionsBusiness units
Small restaurant50–3001.2× items~0.1×5–205–30 / 20–2001
Restaurant group300–1,5001.5×~0.2×20–6030–150 / 200–1,0002–10
Small retail500–5,0002×1–1.5× variants10–1000–51
Larger retail5,000–50,0002–3×1–2×100–5000–101–20
Hotel20–300 services + F&B unit as restaurant1×rare5–30as restaurant2–4

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

8.2 Hot queries and indexes ​

QueryExisting indexNeeded (new migration)
C2 list by name, status, keysetcatalog_items_organization_status_idx (organization_id, status, name) (123) — no catalog_id, no id tiebreak, no lower()items (organization_id, catalog_id, status, lower(name) COLLATE "C", id); replaces the old one only after EXPLAIN (ANALYZE, BUFFERS) proves it unused
C2 prefix on namenone usablecovered by the index above (COLLATE "C" makes LIKE 'abc%' indexable)
C2 prefix on codecatalog_items_code_ci_uq (organization_id, catalog_id, lower(code)) (4426)a text_pattern_ops twin only if the plan shows the unique index is not used for prefix
C2 prefix / exact on SKUitem_variants_sku_ci_uq (4430)same remark
C17 variants of listed itemsitem_variants_item_status_idx (organization_id, item_id, status) (255)none
C17 listing in selected modebusiness_unit_items UNIQUE(business_unit_id, item_id)none
C17 offered groupsitem_modifier_groups_item_idx (2027), modifier_options_group_status_idx (1875)none
C17 primary categoryitem_categories_one_primary_uq (471)none
C18 barcode → variantitem_variant_barcodes_value_uq (organization_id, catalog_id, barcode) (4431)with one catalog this is an exact lookup; when OD4 brings several catalogs, add (organization_id, barcode)
C19 page of listed items by name prefixnone for the listed-and-name orderthe C2 index items (organization_id, catalog_id, status, lower(name) COLLATE "C", id) drives the order; listing joins business_unit_items UNIQUE(business_unit_id, item_id); EXPLAIN (ANALYZE, BUFFERS) on the "larger retail" profile decides whether a dedicated index is needed
C19 barcodeitem_variant_barcodes_value_uq (4431)none
catalog_versions readPK (G2)—

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

8.3 Search strategy ​

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

8.4 What perf:http must measure ​

RouteClassWhy
GET /catalog/outlets/{id}/sellable-items (200 and 304)hotEvery POS start and every refresh
GET /catalog/outlets/{id}/barcode-matcheshotEvery retail scan
GET /catalog/outlets/{id}/sellable-item-search?q=hotPOS search on a large outlet
GET /catalog/items?q=frequentBack-office search
GET /catalog/workspacefrequentBack-office landing
POST /catalog/items, PATCH /catalog/items/{id}administrativeNot in perf:http (D24)

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

8.5 Statement budget ​

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

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


9. Delivery slices ​

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

#SliceCasesDepends onSizeRiskSecurity-critical
K1Catalog foundations in SQL: migration with runtime grants on catalog (USAGE; SELECT/INSERT/UPDATE on the v1 tables; DELETE only on item_categories and item_modifier_groups, the latter revoked by K9c in revision 101; EXECUTE on ensure_default_catalog, lock_hierarchy, assert_variant_listed, resolve_barcode, variant_is_listed), catalog_versions (G2) with triggers, listed_items (G4), four permissions + role mapping (§7.2), capability catalog.items + pionero next version, audit resource kinds and action types. SQL contract tests (database/tests/contracts/) including "runtime cannot DELETE an item", "listed_items ≡ variant_is_listed"——MHighYes (grants, RLS reach, permissions)
K2Module skeleton + create/read/update/lifecycle of items with default variantC3, C4, C5, C7K1MMediumYes (first tenant writes; authorization wiring)
K3List/search, workspace, categoriesC1, C2, C8, C9, C10K2L (split K3a reads / K3b categories if > 400 lines)MediumNo
K4Variants, SKU, barcodesC6, C11K2MMediumNo
K5Modifier groups and item offersC12, C13K2MMediumNo
K6Business-unit assortmentC14, C15, C16K1, K3MHigh (BU-anchored authorization, dual row semantics)Yes (anchored permission)
K7POS reads + perfC17, C18, C19, perf:http rows, EXPLAIN evidence, index migrationK3–K6LHigh (hot path)No
K8CatalogSellableContractC20K6SMediumYes (admission atomic with sales)

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

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

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

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

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

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

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

Leftovers closed (migration 0078_catalog_search_ignores_accents, baseline revision 88) ​

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

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

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

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

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

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

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

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

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

D1 as built (migration 0099, baseline revision 109) ​

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

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

B1 as built (migration 0100, baseline revision 110) ​

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

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

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

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

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

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

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

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

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

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

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

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

Sales columnFrom
bundle_group_idbundleGroups[].bundleGroupId
bundle_group_item_idchoices[].choiceId
parent_bundle_selection_idthe row of the enclosing choice, for choices[].bundleGroups
selection_numberthe position of the choice in its group, from 1
group_name_snapshotgroupNameSnapshot
selected_item_name_snapshot, selected_variant_name_snapshotchoices[].snapshot.itemName, .variantName
selection_countchoices[].quantity
quantity_per_selection_snapshotchoices[].quantityPerSelection
quantity_unit_code_snapshotchoices[].snapshot.salesUnitCode
modifier rows of the component (order_line_bundle_selection_id)choices[].offers[].selections, as a line's
adjustmentsPricing (B3)

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

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

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

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

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

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

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

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

Proof:

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

M1 as built (migration 0101, baseline revision 111) ​

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

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

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

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

200, with ETag the strong tag of the menu:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

BeforeAfter
POST /catalog/items/{itemId}/variants/{variantId}/barcodes, PATCH .../barcodes/{barcodeId}, POST .../barcodes/{barcodeId}/deactivation and .../reactivationPUT /catalog/items/{itemId}/variants/{variantId}/barcodes with { barcodes: [{ barcodeId?, barcode, symbology?, isPrimary?, suspended? }] }
PUT /catalog/items/{itemId}/images with { images } and PUT /catalog/items/{itemId}/variants/{variantId}/images with { images }PUT /catalog/items/{itemId}/images with { images, variants? }, variants being [{ variantId, images }]
PUT /catalog/item-images with { items: [{ itemId, images }] }the same, each entry with an optional ifMatch
json
PUT /api/v1/catalog/items/0193…a1/variants/0193…a2/barcodes
If-Match: "<strong tag of the item, as GET /catalog/items/{itemId} answers it>"
{
  "barcodes": [
    { "barcodeId": "0193…b1", "barcode": "7750182002363", "symbology": "ean13", "isPrimary": true },
    { "barcode": "CAM-OX-M-AZUL", "symbology": "code128", "suspended": true }
  ]
}

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

Images:

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

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

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

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

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

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

Modifier group set:

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

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

A refusal points at the option:

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

Offers:

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

200 OK
ETag: "<new strong tag of the item>"
{ "data": { "itemId": "0193…a1", "modifierGroups": [ … ], "variants": [ { "variantId": "0193…a2", "modifierGroupOverrides": [ … ] }, … ] }, "meta": {} }
OffersAs built
Listsoffers is required and is the whole item list (at most 20); variantOverrides is optional and holds at most 10 variant lists per request, each variant once, each list at most 20 groups. suspended belongs to an item offer and optedOut to a variant override (400 otherwise)
A variant not sentKeeps its overrides, and so does every variant when variantOverrides is absent: a client that manages only the item list never wipes the overrides; a variant sent with an empty list clears them and follows the item again. Chosen as for the images route
RulesUnchanged: offer ids kept for ever (K9b), retire instead of delete, suspended, optedOut, min, max and defaults, the effective-offer rules, the activation judgement of a resumed or opted-in group, the per-variant rules of K9g
RefusalsEach points at its field: offers[i].modifierGroupId (404 · catalog.not_found, 422 · catalog.modifier_group_not_active), .minSelections (422 · catalog.selection_rule_invalid), .defaultOptionIds (422 · catalog.default_options_invalid), under variantOverrides[j].offers[i] for a variant list; variantOverrides[j].variantId for a variant of another item or unknown (404) or archived (422 · catalog.variant_archived); an archived item 422 · catalog.item_archived
Precondition and tagIf-Match required on the item tag, which covers every variant list (428, 412); the answer is the whole item with its new tag
LocksCatalog version row, item row FOR UPDATE, then the groups of every changed list FOR SHARE once, in id order; the variant writes are serialised by the item lock, as every variant write of the catalog
WritesOnly the lists that changed, each judged first and written after: all changed lists or none
GatingPer list as before (only removing, suspending or opting out never needs the plan); the plan is asked once when any changed list needs it
AuditOne catalog.item.modifier_groups_set per changed list, as before
Body ceilingThe same 512 KiB parser as the group set: the largest documented body (20 offers with 50 defaults each, plus 10 such variant lists) fits it; one parser only, mounted behind the bearer credential like the others
Statements21 for the item list and one variant list (29 before the one-statement item read of API v7 PR 6), 5 more per further changed variant list (test/e2e/statement-budget.e2e-spec.ts)
OpenAPIBreaking: the removed PATCH of the group, the option add and edit paths, the variant offers path and the modifierGroups body property; the document moves to 11.0.0
ProofThe C12 and C13 rows of CATALOG_CASE_MATRIX.md

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

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

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

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

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

Listing changes:

json
POST /api/v1/catalog/business-units/0193…09/assortment/listing-changes
If-Match: "<optional: the tag of GET .../assortment>"
{
  "list": ["0193…a1", "0193…a4"],
  "delist": ["0193…a3"]
}
Listing changesAs built
Bodylist and delist, both optional, up to 200 ids each, each once, at least one id in all; an id in both answers 400 with meta.errors[].field such as delist[0]; an unknown field such as itemIds answers 400
Rules keptMode all: a listing lifts an exclusion, a delisting writes one; mode selected: a listing writes an inclusion, a delisting withdraws it; mode none: 422 catalog.assortment_disabled. An archived item is refused only in list (422 catalog.item_archived); an unknown item or one of another organization answers 404 catalog.not_found for the whole change; nothing is written then
Plan and permissioncatalog.assortment.manage anchored at the unit, as before. The plan (capability catalog.items) gates the change only when list names an item; a change that only delists never needs it, so withdrawing from sale always works
AuditOne catalog.assortment.items_listed record naming the items that started selling and one catalog.assortment.items_delisted record naming the ones that stopped, each only when its direction changed something; a change that changes nothing records nothing. A refusal is recorded under items_listed when list names an item and under items_delisted otherwise
Statements and locksOne permission step, the catalog version row locked first, one read of the stored mode, one share of every named item in id order, then per changed direction one insert and one update at most; the answer is the assortment as GET .../assortment reads it
If-MatchOptional, as the owner decided: the assortment is a mode and a membership set, not a whole-collection replace
Item listAs built
Several statusesEach status is its own ordered branch (ORDER BY name_search_key, id LIMIT 51 on catalog_items_name_search_idx) merged with UNION ALL and limited again, so every branch stops at the page; a single status keeps the one scan it always had. A text prefix is judged per branch
Category treeincludeDescendants=true keeps the items classified in the category or in the categories one or two levels below it (categories_parent_idx, no recursion: a tree is at most three deep), whatever their status. Both category filters now read the classified item ids once per statement (id = ANY (ARRAY(...)), an InitPlan) instead of once per scanned item
Image filterhasImage is one keyed probe per scanned item on item_assets_one_item_primary_uq (the item primary image; an image of a variant does not count)
primaryImageRead in the same statement through the same unique index; the page then asks Assets once for the thumbnail of every distinct primary image (AssetDeliveryContract.describe, rendition thumbnail). thumbnailUrl is null while the image is processing, when it failed or when the media host is not configured. The key is always present: null for an item without a primary image, as primaryCategory already does, so a row without image differs from before only by "primaryImage": null
WorkspaceIts first page is read by the same statement and enriched by the same call, so it keeps matching the first page of the list key by key (catalog-item-list-filters.e2e-spec.ts)
CursorBound to the route, the organization and every filter: the statuses (in lifecycle order, so inactive,active and active,inactive share cursors), q, categoryId, includeDescendants when true, hasImage when sent, businessUnitId and listed. A cursor sent with another filter set answers 400 collection.cursor_incompatible; the workspace endCursor is still accepted by GET /catalog/items with no other filter
Plan stabilityRight after importing 2,001 items (two variants each, a third inactive, a primary image on every other item, every fourth item in a three-level tree, the rest in 100 categories) before the statistics know it, a first page of 50 reads: three statuses 332 rows and about 1,060 buffers; the category tree 1,142 rows and about 1,130 buffers; one exact category 833 rows and about 1,050 buffers; hasImage=true 458 rows and about 1,300 buffers, false 410 and about 1,240; two statuses with the tree and hasImage=true 2,080 rows and about 1,630 buffers; no sequential scan and no wide looped scan; the spec bounds each at 2,500 rows and 2,500 buffers (item-list.plan.integration-spec.ts). Before the change an exact category read 88,664 rows (the classified items were scanned again for every item); the tree filter written the same way read 88,880
Plan stability, CI correctionThe CI of the promotion found the primary category of each row read by a looped Seq Scan on categories (102,051 loops) when the categories table was nearly empty at the last ANALYZE, and a text prefix whose SKU branch ran once per scanned item (4,002 rows x 718 loops locally). The row summary now reads the primary category id of each row through item_categories_one_primary_uq (LIMIT 1, never flattened) and every category name of the page once (category_names, a jsonb_object_agg over id = ANY (ARRAY(...))), the prefix and the second tree level are evaluated once per statement (id = ANY (ARRAY(...))), and the plan spec asserts that no scan of categories repeats per row. A second correction after the full integration run (where together read 4,667 rows): the items of a category and the items matching a text prefix are now one candidate set read once per statement (candidates, the two intersected when both are sent) instead of once per status branch, each status branch keeps its ordered scan and tests membership with a filter that is never an index condition (coalesce(id = ANY (...), false)), and the SKU branch of the prefix states sku_search_key IS NOT NULL so its partial index applies. The ordered scan stays (owner decision): a bad plan of an admin list costs a few thousand rows, not a catastrophe. The plan spec therefore checks invariants that hold in the good and in the bad plan: no sequential scan of items, item_variants, item_assets or item_categories, no looped wide scan, no scan of categories repeated per row, three statuses at most three single-status pages, and ceilings of 4 rows and 4 buffers per imported item for the filters and 8 for a text prefix. Good plan: together about 2,480 rows and 2,230 buffers, a one-letter prefix 12,710 rows; bad plan forced with near-empty statistics: together 4,260 rows and 3,350 buffers, an image filter 5,330 buffers; the worst forced with enable_nestloop = off, 6,210 rows. Its fixture now holds 497 categories besides the tree, near the 500 a catalog may hold; with it every filter reads at most about 2,820 rows and 1,550 buffers (bound 3,000 rows and 2,500 buffers) and a prefix about two pages' worth per status
Prefix across statusesA prefix that matches few names (bulk 19) still reads every item of a status under stale statistics (3,666 rows for one status, a cost the prefix filter already had); two statuses read 5,334, never more than two single-status pages, which the spec asserts
Statements7 for a page whatever the filters (several statuses, the tree, the image filter, as before for one status or categoryId), 8 with listed (the unit status, as before), and one more when the page holds primary images (the Assets call): 8, and the workspace 12 without images and 13 with them (catalog-reads-statement-budget.e2e-spec.ts)

Item list request and row:

json
GET /api/v1/catalog/items?status=active,inactive&categoryId=0193…c1&includeDescendants=true&hasImage=true&limit=50
{
  "data": [
    {
      "itemId": "0193…a1",
      "code": "hamburguesa-clasica",
      "name": "Hamburguesa clásica",
      "status": "active",
      "defaultVariant": { "variantId": "0193…a2", "sku": "HB-001", "salesUnitCode": "unit" },
      "variantCount": 1,
      "primaryCategory": { "categoryId": "0193…c1", "name": "Platos de fondo" },
      "primaryImage": {
        "assetId": "0193…e1",
        "thumbnailUrl": "https://media.ninaku.io/a/k3j7m2q9x4v8b6n5c1d0f7g2h4/p1/thumbnail.webp"
      }
    }
  ],
  "meta": { "pageInfo": { "hasNextPage": true, "hasPreviousPage": false, "startCursor": "…", "endCursor": "…" } }
}
Category tree versionAs built
Columncatalog.catalog_versions.categories_version bigint NOT NULL DEFAULT 1, catalog_versions_categories_version_chk (> 0)
Backfill and tag formatEvery existing row takes its current version. The tag also changes form, from organization:version:categories-<status or all> to organization:category-tree:<tree version>:<status or all> (category-tree-entity-tag.ts), so a tag issued from the organization-wide version, before the upgrade or by an old instance during a rolling deploy, can never equal a new one even when the numbers coincide: such a client gets one 200 and the new tag
TriggersThe three statement-level triggers of catalog.categories now run catalog.bump_category_tree_version(): one UPDATE of the version row that moves version as before and categories_version when the statement inserts or deletes categories or changes the parent, code, name, description, sort order or status of one (the update trigger compares OLD TABLE and NEW TABLE); an update that leaves those columns as they were moves only version. No other table moves it, so item, variant, classification, image and assortment writes keep the tree tag
Guardcatalog.guard_catalog_version_step() still demands version + 1 and lets categories_version stay or move by one; the runtime receives UPDATE (categories_version), which the trigger needs, and cannot forge it (contract catalog-category-tree-version-contracts.ts)
Effective visibilityDerived from ancestors, which are categories, so every change that can alter isEffectivelyActive or blockedByAncestorId moves the tree version. The tree body holds categories only (no item counts), so item writes cannot change it
Not changedThe till tag, the assortment tag and every other tag keep the organization-wide version, which category writes still move
Upgrade proofA revision-113 database built from origin/staging, with a catalog and two categories seeded in a superuser DO block under a tenant context, upgraded by db:prepare: its pg_dump --schema-only (privileges included) equals a fresh revision-114 install apart from the \restrict keys, the seeded rows survive and categories_version equals version (6)

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

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

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

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

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

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

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

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

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

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

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

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

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


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

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

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

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

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

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

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

json
PUT /api/v1/catalog/outlets/0193…e1/menus
If-Match: "<the ETag of GET /catalog/outlets/0193…e1/menus>"
{
  "menuIds": ["01930000-0000-7000-8000-0000000000d0", "01930000-0000-7000-8000-0000000000d9"],
  "windows": [
    { "menuId": "01930000-0000-7000-8000-0000000000d0", "days": [1, 2, 3, 4, 5], "from": "12:00", "until": "16:00" },
    { "menuId": "01930000-0000-7000-8000-0000000000d9", "from": "22:00", "until": "02:00" }
  ]
}
json
200 OK
ETag: "<a new strong tag>"
{
  "data": {
    "outletId": "01930000-0000-7000-8000-0000000000e1",
    "menus": [
      { "menuId": "01930000-0000-7000-8000-0000000000d0", "code": "almuerzo", "name": "Menú del almuerzo", "status": "active",
        "windows": [{ "days": [1, 2, 3, 4, 5], "from": "12:00", "until": "16:00" }] },
      { "menuId": "01930000-0000-7000-8000-0000000000d9", "code": "bebidas", "name": "Bebidas", "status": "active",
        "windows": [{ "days": [1, 2, 3, 4, 5, 6, 7], "from": "22:00", "until": "02:00" }] }
    ]
  },
  "meta": {}
}
C31As built
Windowdays (1 Monday to 7 Sunday, each once, every day when omitted), from (inclusive) and until (exclusive) as HH:MM, in the local time of the site of the outlet. until earlier than from crosses midnight and the window belongs to the weekday it starts on (Friday 22:00 to 02:00 ends on Saturday at 02:00). from equal to until is refused
Bodywindows is optional; each entry names its menuId, which must be in menuIds. At most 14 windows per menu (MENU_WINDOW_LIMIT: two service periods on each of seven weekdays), 140 in all. A body without windows, the form that predates them, shows every menu always and so replaces the windows the outlet had: the write is a whole-set PUT, so send the windows read from the GET again to keep them
Validation400 with a pointer that teaches: an hour that is not HH:MM (windows[0].from), from equal to until (windows[0].until), no day, a day outside 1 to 7 or a repeated day (windows[0].days[1]), a window of a menu that is not in menuIds (windows[1].menuId), more than 14 windows for a menu (windows[14]), any other field
OverlapWindows of one menu may touch (one ends when the next starts) but not share a minute of the week, midnight and Sunday to Monday included: 422 · catalog.menu_windows_overlap with meta.errors naming each window that overlaps an earlier one (windows[2]). Refused rather than merged: two windows that share a minute are almost always a typing mistake. Judged also when no menu is new
AnswerEach menu carries windows: [{ days, from, until }] in canonical order (by from, then until, then days), days ascending; [] when the menu is always shown. The tag of the read is the strong hash of the answer, so a window change moves it
No-opThe same windows, in any order and with the days in any order, answer 200 with nothing written, no record and the catalog version unchanged
WritesAs C29 plus, when the outlet has or gets windows, one DELETE of the weekdays and one of the windows before the assignment is deleted, and one INSERT of the windows and one of the weekdays after it is inserted (no foreign key cascades). Each statement moves the catalog version once
AuditThe same catalog.outlet.menus_set record per real change, with catalog.outlet.menus (only when the ids or their order moved) and catalog.outlet.menu_windows (only when the windows moved) before and after; the windows read <menuId> <days> <from>-<until> joined by ; (null for none). No new action code
Tablescatalog.outlet_menu_windows(organization_id, outlet_id, menu_id, window_number, time_from, time_until, spans_midnight, created_at), primary key on the first four, composite foreign key to outlet_menus(organization_id, outlet_id, menu_id), number 0 to 13, and the time check of Pricing (from <> until, spans_midnight exactly when from > until) without any foreign key to Pricing; catalog.outlet_menu_window_weekdays(…, window_number, iso_weekday 1 to 7) with a composite foreign key to the window. Statement-level version triggers, the organization reassignment guard, RLS and SELECT, INSERT, DELETE for the runtime. Contract: database/tests/contracts/catalog-outlet-menu-windows-contracts.ts
Rejected shapesColumns on outlet_menus (a menu needs several windows and each window a repeating set of weekdays), a column on menus (the menu is organization-wide), and weekdays as an array column (no foreign key, no check per element, nothing to probe)
Front endRead the menus with their windows, edit them on screen, send menuIds and windows with the tag read; a menu without a window is always shown

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

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

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

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

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

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

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

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

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

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

Cost. Measured in PERFORMANCE_BASELINE.md, section 20.

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

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

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

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

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

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

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

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

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

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

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

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

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

No operation is added. The design is in CATALOG_OPTIONS_V2.md, "K14d as built".

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

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

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

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

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

Two operations. Design in CATALOG_OPTIONS_V2.md, "K12b as built"; decision DECISIONS.md D41.

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

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

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

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

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

No operation is added. Design in CATALOG_OPTIONS_V2.md, "K15d as built"; decision DECISIONS.md D39.

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

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

Design in CATALOG_OPTIONS_V2.md, "K15c as built"; decision DECISIONS.md D38.

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

10. Owner decisions ​

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

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

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

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


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

#FindingEvidenceRecommendation
R1The runtime cannot reach the schema. No USAGE on catalog; no table grantsruntime_grants.sql ≈ line 35K1, with contract tests for what runtime must not do (DELETE on master tables, EXECUTE on worker-only functions)
R2Dual meaning of business_unit_items. In all mode an inactive row excludes; in selected mode an active row includes. A row that is "active" in all mode does nothingvariant_is_listed, 4487–4502Encapsulate in C15; document in SQL test names; consider a listing_kind column later only if the dual meaning causes a bug
R3Row-at-a-time listing predicate unsuitable for the POS readvariant_is_listed is called per variant, including inside resolve_barcodeG4: set-based twin + equivalence test
R4No updated_at maintenance, and three tables without updated_atno assignment in 0080; catalogs 4229, business_unit_* 4433/4446Application sets it; ETags never rely on it
R5Organization-wide catalog version invalidates every outlet's POS cache on any catalog editG2 designAccepted: edits are rare relative to reads; a 200 re-read is ≤ 6 statements. Revisit with per-business-unit versions if perf:http shows refresh storms
R6Item "default variant" deferred trigger only fires at commit: a use case that forgets the default variant fails with 23514 at COMMIT, after audit and other writes2979–2991Domain rule in TypeScript prevents it before SQL; the trigger stays as backstop; map its constraint to catalog.default_variant_required
R7READ COMMITTED is asserted by ensure_default_catalog, lock_hierarchy, guard_assortment_change, assert_variant_listed, resolve_barcode4245, 2999, 4460, 4504, 4528Assumption: the tenant transaction runner uses READ COMMITTED (PostgreSQL default). K1 adds a test that would fail if it changed
R8resolve_barcode takes FOR SHARE locks even for a pure lookup4504–4545C18 uses a lock-free read query, and admit (K8) does not call it either: no application path uses it, but it stays with its grant (the database contracts exercise it, and its assert_variant_listed is called by the Sales schema's order-line trigger)
R9guard_assortment_change locks the business unit row FOR UPDATE per row; a 200-item listing locks the same row 200 times and contends with Organization structure writes on that unit4460–4485Bound calls to 200 ids; measure; acceptable for configuration traffic
R10catalogs.code check is weaker than every other code (only non-blank, no pattern)4229–4243Tighten in a later migration when additional catalogs ship (OD4)
R11Generic tags have no reader§3.1OD9
R12Food data shape may move (catalog_food) while production, crm and sales already reference allergens/dietary_tagsDOMAIN_MAP.md lines 38, 78; FKs in 0110, 0180, 0200Decide the boundary before C24; do not expose these tables before
R13No menu-to-outlet assignment (closed: M2a assigns menus to outlets, M2b reads them in the till; kept as the original design finding)G1OD7
R14modifier_groups has no default selection rule: min/max live only on each item link, so "Choose a side (1 required)" is repeated per item1672 vs 1937Accepted for v1 (one place to read); a group-level default can be added if owners complain
R15Canonical SQL keeps comments; new migrations cannot add any (lint:no-comments, shrinking allowlist)AGENTS.md §4K1 SQL is comment-free; intent lives in test names and this document
R16Audit retention class for tenant configuration actions: organization actions use administration.platformmigration 0039Assumption: reuse it for catalog configuration actions unless the audit owner defines a tenant configuration class
R17Deleting link rows. item_categories and item_modifier_groups have FOR ALL policies, so DELETE is RLS-allowed once granted (since revision 101 only item_categories is granted: K9c); the audit trail must carry before/after since the rows vanish3980, item_modifier_groups_tenant_policyC8 and C13 audit the complete before/after lists
R19The Sales order-line trigger is unit-keyed. It calls catalog.assert_variant_listed, which does not know the outlet, so it enforces neither the hiding of an outlet nor the listing of an option target0110_sales.sql; K16, K16bKnown limit, a Sales decision: Catalog refuses at resolve and admit, which Sales calls first; moving the trigger to catalog.variant_is_listed_at_outlet is Sales'
R18organization.effective_business_unit returns NULL for an outlet with no active assignment on the date; C17 and C19 answer an empty list (not 500) for a caller the permission step admits0020_organization.sql, function and comment (verified)K7 test rows (C17, C19)

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


12. Glossary ​

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

Application Foundation in progress. Tracked in issue #13.