Skip to content

Catalog Options and Modifiers v2 ​

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

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

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

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

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

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

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


Quick path ​

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

1. Why a v2 ​

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

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

ConceptWhoUsed here as
Quantity per option; group limits on the sum; max may exceed the option count when quantities are allowedSquare CatalogModifierList.allow_quantities, max_selected_modifiersOV5
Per-item override of min/max, enabled, order, receipt visibilitySquare CatalogItemModifierListInfoOffers; per-offer overrides (K14)
Per-item override of one modifier's default and online visibilitySquare CatalogModifierOverrideOffer defaults (exist); overrides (K14)
Text modifiers with maximum length and "text required"Square modifier_type = TEXT, max_length, text_requiredText options (K14c)
Prefixes No / Add / Sub; required and allowed condiment groups with min and maxOracle SimphonyPrefix hint (K14c)
Size and sequence pricing; portions with a price scale factorToastPricing's work; portions deferred
minRequired, maxAllowed, showByDefaultCloverSame model
Modifiers separate from production instructionsLightspeed K-SeriesPrefix is a hint; effect is Production's
Charged once, per night, per person, per person per nightMewsPricing's charge basis, never Catalog
Up to 2,048 variants, 3 options per productShopifyVariants and structured attributes (H6), not modifiers
Combo choices with extra priceOdooBundles (C22)

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

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

3. Decisions ​

#DecisionStatusAlternatives weighedWhy
OV1An offer is the link (item or variant) × group with its rules; its UUID is stable for ever: editing min, max, order or defaults updates the row in place; only a genuinely new link gets a new UUIDDecided (architect H1)Delete and re-insert (today): breaks FKsSales, Commerce and Pricing reference offers
OV2Offer status vocabulary becomes active | inactive | retired. Retired = no longer offered: kept only for references and history, ignored by every read, sellability and requirement check; re-offering the same group revives the same row (same UUID). Inactive = still offered but suspended (item level) or an explicit opt-out (variant level, OV9)Decided (architect H1: "retirar ≠ suspender")Retire as inactive (this document's first draft): requiredGroupsSatisfiable reads it as "required but unavailable" and hides the item§4.1
OV3Retired rows stay inside the two partial unique indexes: one row per logical link for everDecided (designer), diverging from the instruction to exclude retired rows from the unique indexesExclude retired rows (index WHERE status <> 'retired'): a re-offer could insert a second row for the same link instead of reviving, breaking OV1, and two rows would compete for one Pricing keyRevival is deterministic only with the full index; a separate partial index serves the live reads (§4.1)
OV4Preserving a UUID does not freeze historical rules: Sales freezes names, quantities and charges on its own rows (0110_sales.sql:1187-1199); Pricing keeps dated price rows (0090_pricing.sql:442-443); Catalog keeps only the current ruleDecided (architect note)Catalog revisions of offers: no consumer needs them while the consumers snapshot—
OV5Group min/max count units: the sum of quantity over the offer's selections; maxQuantity bounds one option; quantity is an integer count 1–999Decided: the Sales SQL is the executable authority (0110_sales.sql:1190, :1271-1274, :4097-4101, :4229-4247)(a) distinct options: contradicts Sales. (b) both rules in v1: no reader needs it yetA separate maxDistinctOptions on the offer is Proposed (§4.4)
OV6A measurable option amount (0.25 kg extra) is not a quantity; it would be a separate, unit-bearing value (§4.4)ProposedDecimal quantity: Sales stores smallint; "two nights" or "ten sessions" are counts or a vertical's validity, not measurable modifiers (architect §4)—
OV7A preselection is never an accepted selection: the sale command sends what the customer kept; admit never adds defaultsDecided (built in K8, CATALOG_USE_CASES.md:1474; architect §4)——
OV8The contract answers the effective offer: itemModifierGroupId, modifierGroupId, source: 'item' | 'variant', minSelections, maxSelections; Sales stores the offer admit returned, never one looked up laterDecided (architect H3)Consumers query catalog.item_modifier_groups: breaks the module boundary§5
OV9Effective offer of variant V for group G: an active variant override replaces the item offer; an inactive variant override is an explicit opt-out (G not offered on V, no minimum); a missing or retired override inherits the item offer; an inactive item offer is suspended (if required, the variant is not configurable). One SQL fragment serves the till, the search, the scan, resolve and admitDecided (owner, via coordinator; the review left the inactive override open)(a) inactive override inherits (Sales until migration 0090): no way for one variant to drop an item-level group. (b) "offer only on the variants that have it" (first draft): fails when the item-level offer exists for many variantsSales aligned (X13, migration 0090)
OV10Overrides replace, never add: the variant rule is not summed with the item ruleDecided (architect H4)——
OV11A required group is satisfiable when its minimum is at most the sum of the effective maximum quantities of its available optionsDecided (architect functional review, F1; built ahead of K9h, §4.2 As built (F1))Count of options (before F1)Same arithmetic as OV5
OV12Catalog reads of Organization and Platform go through owner-published surfaces: TypeScript contracts sharing the opaque TransactionContext by default, and, as a documented normative exception, owner-published SQL read functions for the single-statement hot reads; Organization's vertical activation calls a Platform contractRecommended (architect H7)(a) contracts only: the till read and admit would lose their one-statement snapshot and statement budgets (CATALOG_USE_CASES.md:1453). (b) leave as is: violates ARCHITECTURE.md:219§6
OV13Option kinds: instruction, catalog_variant, new text; quantity-only = any option with maxQuantity > 1RecommendedText as a group type; generic input kind§4.5
OV14Prefix (add, extra, light, no, on_the_side, substitute) is a hint on the option; never a sale-time dimensionRecommendedToast/Oracle pre-modifiers: need a Sales column and a Pricing multiplier§4.6
OV15Removed defaults are derived by admit (removedDefaults), not stored by CatalogRecommendedNegative quantities: Sales refuses them§4.7
OV16Defaults carry a quantity; per-offer option overrides exclude an option or lower its maximum, never raise itDecided; default quantities built in K14a ("K14a as built") and the per-offer option limits in K14b ("K14b as built")—Sales checks the option's own max_quantity (0110_sales.sql:4097)
OV17Runtime availability ("86") of options is Operations'RecommendedA catalog status flip invalidates every till0140_operations.sql:4598-4622
OV18Modules and data stay decoupled: no new cross-schema FK; no copy of a neighbour's fact (price, stock, tracking, tax, discount, channel mapping); neighbours get data through contracts or their own immutable snapshotsDecided (owner)—§17
OV19Variant attributes are structured, neutral dimensions and values with persistent links; the matrix creates them; no shirt_size columnsDecided (architect H6)Concatenated codes and names (today)§8
OV20Offline replay accepts a sale made under an earlier context only on explicit evidence (§7.3)Recommended; owner question Q4Re-admit against the current catalog: refuses legitimate past sales (architect H5)—
OV21item_kind is Proposed only, with named readers (§17.1) and the constraint that Catalog never decides kitchen, stock, tax or membership from itProposed (architect §12)Add nowNo reader exists yet
OV22The admission line carries its quantity and Catalog judges it against the variant's sales quantity policyRecommendedLeave to SalesThe policy is a catalog fact (0080_catalog.sql:140-200)
OV23Portions and nested options: deferred with fixed shapesProposed—§4.8, §4.9
OV24The catalog stays abstract and is administered per business unit or per outlet without per-channel flags: the business unit stays the unit of assortment and an outlet may only hide items from it (K16), never sell what its unit does not listDecided (owner)—§10 (K16), §21 ("K16 as built")
OV25The till read (C17) sends only the categories that hold something the outlet sells now: an item whose primary category is the category or one of its descendants, with the ancestors that lead to it; there is no parameter to show the others, and the till shows its own message from what it receivesDecided (owner), builtKeep empty categories (the earlier decision, so a folder the owner just filled is not hidden before the till refreshes); a flag to show themCATALOG_USE_CASES.md C17

4. Capability model ​

4.1 Offer identity, retirement and suspension (H1) ​

States of an offer row.

StateMeaningSellability and readsSales/Commerce/Pricing see
activeoffereduseda valid target (their triggers require active, 0110_sales.sql:4090-4095, 0100_commerce.sql:1975-1980)
inactive at item levelsuspended: still offered, temporarily unavailablerequired → variant not configurable; optional → omittedrefused as a new selection; history intact
inactive at variant levelopt-out: group not offered on that variant (OV9)group omitted for that variant, no minimumno minimum; a selection through the item offer is refused as not offered (X13, migration 0090)
retiredno longer offered; kept for referencesignored everywhere (never "unavailable", never required)refused as a new selection; history intact

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

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

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

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

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

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

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

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

Acceptance criteria (architect §3).

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

4.2 Effective offers (H3, H4) ​

text
effective(V, G), for variant V of item I and group G offered on I or on V:
  if a variant row (V, G) exists with status 'active'   -> that row      (source 'variant')
  if a variant row (V, G) exists with status 'inactive' -> not offered    (opt-out)
  otherwise (no variant row, or a retired one):
    item row (I, G) 'active'   -> that row                                (source 'item')
    item row (I, G) 'inactive' -> suspended: min > 0 makes V not configurable; else omitted
    item row (I, G) 'retired' or missing -> not offered
order: sort_order of the effective row, then source 'variant' first, then id
Case (architect H4)Result
Active overridereplaces the item offer for that group
Missing overrideinherits
Inactive overrideexplicit opt-out (OV9)
Required group with no available option (or fewer units than the minimum, OV11)that variant is not sellable; the other variants of the item stay sellable
The till (C17), the search (C19), the scan (C18), resolve and admitthe same fragment, proven by one parity test

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

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

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

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

4.3 Per-variant offers: writing them ​

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

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

4.4 Quantity semantics (H2) ​

Concept (architect §4)ExampleTreatment here
Base quantity of a variant0.25 kgExisting decimal sales policy (0080_catalog.sql:153-187); judged by admit (OV22)
Count of an option2 shots, 2 extrasquantity, integer 1–999 (OV5)
Measurable amount of an option0.25 kg extraProposed (OV6): an option flag measured_unit_code plus measured_min, measured_step, measured_max; the selection carries amount (decimal string) instead of quantity; Sales needs amount and amount_unit_code columns; Production multiplies its add effect by it (0180_production.sql:6285-6289)
Validity of a servicea one-month membershipThe Gym vertical's rule, not a quantity

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

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

CaseResult
Cheese × 2expressed once with quantity: 2, admitted when min ≤ 2 ≤ max
Same min/max in contract and SQLproven by the parity database contract (§9.4)
Quantity above maxQuantityoption_quantity_above_maximum
Repeated option, quantity 0, negative, non-integer, malformed idCatalogSellableRequestInvalidError (duplicate, invalid_quantity) before any SQL
Preselectionnever added by admit; the command sends what was kept
Measurable amountsnot accepted in v1 (invalid_quantity for a decimal)

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

4.5 Option kinds ​

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

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

4.6 Prefixes ​

PrefixProduction effect it names (0180_production.sql:1232-1235)Rule
addaddany quantity
extraadd (quantity multiplies, :6285-6289) or scalescale needs quantity 1
lightscale < 1maxQuantity = 1
noremovemaxQuantity = 1, instruction only
on_the_sidenone (kitchen hint)maxQuantity = 1, instruction only
substitutereplacemaxQuantity = 1, instruction only

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

4.7 Defaults and removal of a default ​

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

4.8 Portions (deferred) ​

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

4.9 Nested options (deferred) ​

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

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

TopicOwner and mechanism
Option sold out at an outletcatalog_variant: Operations on the target variant (0140_operations.sql:4598-4622); instruction: Operations twin table (Q2)
Option only at some business unitsper-item or per-variant offers; per-option assortment Proposed
Option per daypartmenus (OD7)
Allergens of optionsinherited from the target item for extras; instruction options wait for catalog_food (R12)
Kitchen routingKitchen (0150_kitchen.sql:607-637, :725-735); Catalog adds printsOnKitchenTicket (K14c)
ReceiptprintsOnReceipt (K14c); Printing lays out
TranslationsC23 (0080_catalog.sql:1413, :1576)
Taxper variant (0260_tax.sql:646-663); no tax hint in Catalog
Charge basis (once, per night, per person, percentage)Pricing; not expressible today (X6)

5. Contract shapes (H2, H3) ​

5.1 Admission (K9d, K9e) ​

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

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

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

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

Sales columnFrom
item_modifier_group_idoffers[].itemModifierGroupId
modifier_option_idselections[].modifierOptionId
group_name_snapshot, option_name_snapshotthe snapshots
quantityselections[].quantity
quantity_unit_code_snapshottargetSalesUnitCode (required for catalog_variant, forbidden for instruction, :4103-4118)
chargesPricing

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

5.2 Till ​

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

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

5.3 What each neighbour receives ​

NeighbourReceives through the contractNever receives
PricingitemModifierGroupId, source, modifierOptionId, kind, targetVariantId, quantitya price
DiscountsmodifierGroupId, modifierOptionId, variantId, itemId (targets at 0090_pricing.sql:1638-1720)rules
InventorytargetVariantId × quantity for extrasstock
ProductionmodifierOptionId × quantity for instructionsrecipes
Taxline variant, targetVariantIdclasses
Commercethe same admission shape—

6. SQL ownership (H7) ​

6.1 Accesses to replace ​

Catalog fileOwner table readUse
infrastructure/items/item.queries.ts:84organization.business_unitsunit exists (filters)
infrastructure/sellable/sellable.queries.ts:69, :109organization.business_unitsunit operable; admission share lock
infrastructure/barcodes/barcode-lookup.queries.ts:18-22, :66organization.effective_business_unit, outlets, sitesoutlet → unit on the site date
infrastructure/assortment/assortment.queries.ts:3, :55organization.business_unitsunit state
infrastructure/reads/item-list.queries.ts:64organization.business_unitslisted filter
infrastructure/reads/catalog-read.queries.ts:49-50organization.business_units, platform.business_unit_verticalsworkspace units and verticals
infrastructure/till/till-read.queries.ts:43, :187organization.structure_versionstill ETag
Organization: business-unit-vertical.repository.ts:22-45 (gone since K10c; Platform owns the write in platform-business-unit-verticals.repository.ts)writes platform.business_unit_verticalsvertical activation

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

6.2 Recommendation (OV12) ​

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

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

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

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

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

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

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

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

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


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

7.1 What exists and what is missing ​

ExistsWhere
Till reads per outlet, ETags, catalogVersion, keyset search, 2,000-item cap with truncatedCATALOG_USE_CASES.md:1450-1454; sellable-read.ts:7
Sync SQL: devices, context assignments, bootstrap sessions, commands, conflicts, change streams and batches with acknowledgements0370_sync.sql:185, :228, :272, :475, :498, :551, :727, :784-853
Events SQL: domain events, publications, outbox0360_events.sql:127, :167, :202
Missing (analyst and architect findings)Evidence
Catalog publishes no domain eventsno outbox write in src/modules/catalog. Closed by K12a (migration 0114): the writes are SQL triggers of the catalog schema calling events.append_tenant_event, so src/modules/catalog still holds no outbox write by design (D40)
No tombstones: a retired or delisted thing produces no change a device can applyno change feed; offers deleted until K9b. Closed by K12a: a retirement is a retired change with retiredAt
The version triggers cover 12 catalog tables only; menus, bundles, translations, item_assets, allergens, dietary tags and nutrition bump nothing0080_catalog.sql:4139-4272, :4480-4488. Closed for every table a device reads (31 tables carry the triggers at K12a; the 17 translation, allergen, dietary tag and nutrition tables are read and written by no use case yet and get theirs with C23 and C24)
The till answer has no SKU and no barcodes, so a downloaded catalog cannot scansellable-read.ts. Closed by K12b: the bootstrap pages carry both
External ids exist for variants onlyintegrations.external_mapping_catalog_variants (0350_integrations.sql:563)
No Sync module, device grant or revocation, composed offline view, or replayarchitect H5

7.2 Design outline ​

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

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

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

7.4 Acceptance checklist (architect H5) ​

CriterionPlanned evidence
Bootstrap of more than 2,000 items with a consistent cute2e: 3,000 items, a write during the bootstrap, the device ends at one version with no duplicate or gap
Local barcode for a downloaded variante2e: scan resolved from the bootstrap index
Retiring an item removes it from the local offerdelta with a tombstone applied
Retrying a delta or a sale has no double effectidempotency by event id and command id
Switching business keeps catalogs, prices and queues apartdevice-scoped stores per business unit
A permission change rebuilds or invalidates the local scope by a defined policyRecommended: revocation invalidates the device grant at the next session; a scope change forces a new bootstrap
Offline sales and printing survive a device restartclient persistence; out of the API
Stock, payments and fiscal consequences tested with their ownersInventory, Payments, Fiscal
Stock oversell / reserve / offline allotmentInventory's policy, owner question Q5

8. Structured variant attributes (H6) ​

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

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

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

TableColumnsRule
variant_dimensionsid, organization_id, catalog_id, code, name, sort_order, statuscatalog-level ("Size", "Color"), so "every blue variant" spans items
variant_dimension_valuesid, organization_id, catalog_id, dimension_id, code, name, sort_order, statusvalue identity survives a rename and carries translations later
item_variant_dimensionsitem_id, dimension_id, sort_orderwhich dimensions define this item's variants; at most 3
item_variant_dimension_valuesitem_variant_id, dimension_id, dimension_value_idone value per (variant, dimension); the dimension must be one of the item's

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


9. Concurrency, performance, security, tests ​

9.1 Concurrency and H8 ​

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

9.2 Performance ​

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

9.3 Security ​

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

9.4 Case-matrix rows (planned titles) ​

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

C20 (K9d–K9e):

CaseExpectedPlanned test
3 × one sauce in a group of max 3; 4admitted; selection_above_maximumsrc/modules/catalog/domain/sellable/sellability.spec.ts › judges every group on the sum of the chosen quantities, as the sales schema does
Cheese × 2 with minimum 2admitted… › meets a minimum of two with one option chosen twice
Quantity above the option maximumoption_quantity_above_maximum… › refuses a quantity above the option maximum, naming the option
0, 1000, 1.5, negative; an option twiceinvalid_quantity, duplicate before any SQLsrc/modules/catalog/application/sellable/catalog-sellables.spec.ts › rejects a selection quantity outside 1 to 999 or not an integer, and an option repeated in a line, before any lock
Line quantity off policyquantity_out_of_policysrc/modules/catalog/domain/sellable/sellability.spec.ts › refuses a line quantity below the minimum, above the maximum or off the step of the variant
The answer carries the effective offeritemModifierGroupId, modifierGroupId, source, min, max, names, kinds, target unittest/e2e/catalog-sellable-contract.e2e-spec.ts › admits quantities and answers the effective offer identity and everything a sale line stores
Removed and reduced defaultsderivedsrc/modules/catalog/domain/sellable/sellability.spec.ts › derives the removed and reduced defaults of each offer from the final selection
Parity with the Sales triggerrows built from the answer pass validate_line_modifier_insert and assert_order_line_modifier_consistencydatabase/tests/contracts/catalog-sales-admission-parity-contracts.ts › NINAKU_CATALOG_SALES_ADMISSION_PARITY_OK

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

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

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


10. Slices (architect order) ​

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

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

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

10.1 Coverage snapshot (baseline revision 117) ​

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

MeasureBuiltOfShare
Use cases (CATALOG_USE_CASES.md, C1 to C28)232882%
Catalog tables with TypeScript that reads or writes them203754%
Roadmap slices (this section and §18 to §21)244257%
Overallabout 58%

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

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

11. What the neighbours must do ​

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


12. Contradictions and findings ​

#FindingEvidenceResolution
X1Offers deleted and re-created on every edit; referencing FKs have no cascademodifier-group.queries.ts:158-175; 0110_sales.sql:1229-1237; 0100_commerce.sql:1048-1056; 0090_pricing.sql:470-478K9a–K9c
X2Commerce's trigger lacks variant precedence and group min/max0100_commerce.sql:1871-1990Commerce
X3No column for a text option value in Sales or Commerce0110_sales.sql:1177-1307Sales, Commerce
X4A removed default cannot be persisted0110_sales.sql:1271-1274Sales (Q3)
X5Pricing's delta may be negative; Sales refuses negative charges; no removal credit0090_pricing.sql:424-425, :509-513; 0110_sales.sql:1276-1280Pricing
X6No charge basis beyond "per base unit of the line"0090_pricing.sql:484-491; 0110_sales.sql:1198Pricing
X7No Sales column for the admitted catalogVersionCATALOG_USE_CASES.md:1465Sales
X8An extra with its own tax class is taxed in the line subject0260_tax.sql:1162-1180; 0110_sales.sql:1807Tax
X9Sales/Commerce accept inactive groups or targets; Catalog refuses them0110_sales.sql:4090-4095harmless while admit runs first
X10Sales' kind check has no branch for text0110_sales.sql:4103-4118Sales must add a text branch before it consumes text options; K14c and K14d only publish them (Catalog-only: Sales does not exist yet)
X11Production needs quantity 1 for remove/replace/scale0180_production.sql:6300-6304K14c prefix checks: light, no, on_the_side and substitute need maxQuantity 1; extra as a scale effect cannot be checked by Catalog because the effect is Production's (OV18) and Production refuses it at sale time
X12MODULE_CONTRACTS.md names the lock timeout; the measured limit is the statement timeoutMODULE_CONTRACTS.md:357; CATALOG_USE_CASES.md:1469K9e sentence update
X13Sales ignores an inactive variant offer and falls back to the item offer (img.status = 'active' filters before DISTINCT ON), contradicting OV9's opt-out; it would require the item-level minimum on an opted-out variant0110_sales.sql:4200-4225, :4120-4134 (before 0090)Closed by K9g-0 (migration 0090): Sales chooses the variant row among active and inactive first, then drops the group when it is inactive, and ignores retired
X14Catalog names Organization and Platform tables; Organization writes Platform's table§6.1K10
X15Version triggers miss menus, bundles, translations, item_assets, allergens, dietary tags, nutrition0080_catalog.sql:4139-4272add with each table's first use case (K12a rule); item_assets closed by migration 0095, bundles by 0100 (B1, §19), menus by 0101 (M1, §21). Checked at K12a by a systematic query: every table the till and the sellable reads touch carries the triggers; the 17 tables without them (translations, allergens, dietary tags, nutrition) are read by nothing, and a database contract lists them so a table added without a decision fails
X16No catalog events, no tombstones, external ids for variants only§7.1Closed by K12a (events and tombstones, migration 0114); other external mappings when a channel needs them

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


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

JourneyProvesSlice that makes it runnable
Restaurant: two wing variants, one sauce group, different limitseffective offer and overrideK9f
Restaurant: cheese × 2, a per-offer price and historyquantities, offer identity, snapshotsK9e + K11
Retail: T-shirt size/colour, barcode and price by contextvariants, attributes, compositionK13b + Pricing
Retail: 3,000 items and scanning offlinecomplete bootstrap, local indexK12b
Gym: membership + protein + T-shirt in one purchaseshared core, Gym's own consequencesfuture Gym vertical (criterion, not yet run)
Hotel: minibar charged to a stayHospitality integration without a room as an ItemPhase 10 (criterion)
Mixed organization: edit the master, offer per businesssharing without mixing assortmentsbuilt (K6), re-proven after K10b
Offline: sale before a retirement, then replayexplicit reconciliationK12c
Concurrency: sales while another unit is editedthe real lock scope and admission budgetK11 (H8)

14. What stays out ​

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


15. Open owner questions ​

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

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


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

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

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

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

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

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

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

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

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

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

ColumnVerdictReasonCoupling check
every columnkeepunique while active (:3976); one active primary (:332-338)—
barcodechange (domain rule, no SQL)validate the GS1 check digit for ean13, ean8, upca, upce—
units_per_scannot addedpackaging is Inventory's (:26)—

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

ColumnVerdictReasonCoupling check
every column of categories and item_categorieskeephierarchy and effective visibility are correct (architect §11)—
categories.descriptionchange (bound, built in K15a)unbounded (:362)—
tax or kitchen meaningnot addedowners map categories themselves (0150_kitchen.sql:637)—

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

Table.columnVerdictReasonCoupling check
item_modifier_groups.statuschange: add retired (K9a)OV2keeps neighbour FKs valid without Catalog knowing them
item_modifier_groups.item_variant_idkeep, now used (K9f–K9g)OV9—
item_modifier_group_defaults.quantityadded (K14a, 0108)OV16—
item_modifier_group_option_overridesadded (K14a, 0108; read and written since K14b)OV16inside catalog
modifier_options.option_kind (+text), text_max_length, prefix, prints_on_receipt, prints_on_kitchen_ticketchange/add (built in K14c, 0110)§4.5, §4.6hints only
modifier_groups.descriptionchange (bound, built in K15a)unbounded (:1372)—
price, charge basis, recipe effect, stock linknot addedPricing, Production, Inventory—

17.6 Assortment, item_assets, catalogs ​

Table.columnVerdictReasonCoupling check
business_unit_catalogs, business_unit_items: every columnkeep—existing FK to organization.business_units; reads move behind K10
…updated_at on bothadd with the next migration that touches themR4 (CATALOG_USE_CASES.md:1553)—
business_unit_items.item_variant_idnot added (Proposed)per-variant assortment waits for a journey (architect §11)—
item_assets.catalog_idadd in the A5b migrationevery other link row carries it (:3853-3880)inside catalog
item_assets other columnskeepASSETS_STAGE_SPECS.md:1192-1208existing FK to assets.assets; no URL copied
catalogs.code pattern, catalogs.updated_atchange/add when OD4 brings more catalogsR10 (CATALOG_USE_CASES.md:1559)—

17.7 Migrations for K15 ​

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

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

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

18.1 The decision and what exists ​

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

As built (revision 108):

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

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

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

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

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

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

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

18.4 The default: options ​

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

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

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

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

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

18.5 What the build must prove ​

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

18.6 Blast radius ​

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

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

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


19. B1: the composition of a combo ​

19.1 B1 design ​

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

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

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

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

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

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

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

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


20. B2: admitting a combo ​

20.1 B2 design ​

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

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

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

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

Judgement, the same rules as modifiers and as Sales.

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

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

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

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

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

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

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

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

20.3 B2b as built (no migration) ​

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

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

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

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

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

21. M1: the structure of a menu ​

21.1 M1 design ​

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

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

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

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

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


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

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

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

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

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

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

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

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

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

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

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

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

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

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

M2a as built (migration 0105, baseline revision 115) ​

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

22. Sources ​

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

What changed from section 8:

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

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

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

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

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

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

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

RuleFieldCHECK
kind is instruction, catalog_variant or text; a text option names no variantkind, variantIdmodifier_options_kind_chk, modifier_options_kind_target_chk
textMaxLength is a whole number from 1 to 500 for a text option and absent for any other kindtextMaxLengthmodifier_options_text_max_length_chk
A text option holds exactly one unit (maxQuantity 1) and no prefix or addmaxQuantity, prefixmodifier_options_text_shape_chk
prefix is one of add, extra, light, no, on_the_side, substituteprefixmodifier_options_prefix_chk
light, no, on_the_side and substitute need maxQuantity 1maxQuantitymodifier_options_prefix_quantity_chk
no, on_the_side and substitute apply to instruction options onlyprefixmodifier_options_prefix_kind_chk

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

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

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

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

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

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

ReasonWhen
text_requiredthe option is of kind text and the selection carries no text
text_not_allowedthe option is not of kind text and the selection carries text
text_invalidthe text is blank (only white space) or holds NUL or any control character, a line break and a tab included
text_too_longthe text has more characters than textMaxLength, which never exceeds 500

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

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

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

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

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

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

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

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

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

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

EventRootChildren
catalog.item.changeditemsitem_categories, item_assets, item_regulatory_flags, item_variant_dimensions
catalog.variant.changeditem_variantsitem_variant_identifiers, item_variant_dimension_values
catalog.barcode.changeditem_variant_barcodes
catalog.offer.changeditem_modifier_groupsitem_modifier_group_defaults, item_modifier_group_option_overrides
catalog.modifier.changedmodifier_groupsmodifier_options
catalog.bundle.changedbundle_groupsbundle_group_items
catalog.menu.changedmenusmenu_sections, menu_entries, menu_entry_variants
catalog.category.changedcategories
catalog.listing.changedcatalogs, business_unit_catalogs, business_unit_itemsoutlet_hidden_items, outlet_menus, outlet_menu_windows, outlet_menu_window_weekdays

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

What the feed does not promise.

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

Evidence.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Application Foundation in progress. Tracked in issue #13.