# Vocabulary

Sources of truth: `main/app/models/catalog.py`, `main/app/models/decisions.py`,
`main/app/services/product_lines.py`, `main/app/services/attributes.py`,
`main/app/services/decisions/` (`main/app/services/overrides.py` is a one-release shim over it),
`.logs/planning/catalogue-model-decisions-2026-09-15.md`,
`.logs/planning/catalogue-refactor-plan-2026-09-16.md` (W2),
`.logs/planning/decisions-model-spec-2026-09-16.md`, `main/tests/test_one_term_per_concept.py`.

The words the catalogue is talked about in, one meaning each, with the test that decides
whether a sentence is using the right one. Decided 15 Sep (the catalogue decisions, §1) after a
session in which two things shared a word and one thing had two, and the owner said the
language itself was making the model hard to hold. When a sentence fails a test, change the
word, not the test. The last column is the code's name for the thing; since the consistency
pass (Stream K1, migration `c1d2e3f4a5b6`) it is the same word, and
`tests/test_one_term_per_concept.py` fails on any retired identifier that comes back.

> **Rian's standing rule, 16 Sep: one term per concept, everywhere.** *"I want the technical
> language in the database to match what I'm being told as a human in the labeling. It's so
> confusing when we use different language for the same thing."* A concept has one word in the
> database, the code, every label and every sentence said to him. Which word matters less than that
> there is only one (*"I can understand the term canonical and alias, so I would be happy with one
> or the other, but not both"*).
>
> **And never a bare "product" (rian, 16 Sep):** *"I find myself referring to both variations and
> product lines as products... maybe the best idea is just to include the word product in the name
> but be specific about which one we're talking about always."* So: **product line** (the searchable
> thing, the page at `/products/`) and **product variant** (the barcoded thing). A sentence that says
> "product" alone is ambiguous and is rewritten.

> **Agreed 16 Sep: attribute, and the buttons Confirm same / Keep separate.** "Option" and
> "property" are both dropped as nouns: a variant has **attributes** (size, shade, concentration,
> ABV, country), one value each. What differs between kinds is two settings on the kind, not two
> kinds of thing: **does it decide sameness** (size yes; country no) and **how the product line
> page shows it** (picked, shown, or just a fact). Rian: *"they all just seem like attributes of a
> variant."* The desk's buttons are **Confirm same** and **Keep separate**, and the database and
> code say the same words.

## The things

| Word | Test | Example | Code says |
|---|---|---|---|
| **brand** | the name on the front of the pack, as a shopper says it; the row every spelling resolves to, aliases followed | Rabanne, Chanel, Toblerone; Coke, not Coca-Cola | `brands`, `Brand`; `brand_of()` on the keying maps resolves a row or a spelling to it; in SQL beside the stored row it is `brand_of` |
| **listed brand** | the shop's own spelling of the brand, the listed layer | "PACO RABANNE" on one shop, "Rabanne" on another | `listings.listed_brand`; `listed_brand` wherever a function also holds the brand row |
| **alias** (of a brand or a product line) | a spelling that stands for the row it points at, pointed by a person; both rows survive, and the alias row keeps catching that spelling in every later collection | Paco Rabanne, an alias of Rabanne | `alias_of_id` (on the alias row, holding the target's id) |
| **product line** | what you would type into a search box; the name with the brand and every attribute taken off; one page at `/products/` | Johnnie Walker Blue Label; 1 Million (over its EDT, Elixir and Parfum at every size); Rouge Allure; Macallan 18 (a drink keeps its age); Coke Zero | `product_lines`, `ProductLine`, `product_line_id`, `product_line_key()`, `product_line_for()`; the rules in `services/product_lines.py` |
| **product variant** | one product line at one set of attributes; what a barcode names; always written with its quantity | Blue Label 1L; 1 Million Elixir 100 ml | `product_variants`, `ProductVariant`, `variant_id` (and `pinned_variant_id` on a listing) |
| **attribute** | a value that tells one variant from its siblings, or a fact about a variant; a word the rules take out of the name, plus the quantity every variant has; a word the rules leave in the product line is never an attribute | size 1L; concentration Elixir; shade 99 Pirate (from "Rouge Allure 3.5 gr / 99 Pirate"); flavour Milk (from "Lindor Truffles 200 g / Milk") | `product_variants.attributes` JSON: `attribute` with its `attribute_kind` (the registry keys each kind by name); `attribute_aliases`, `AttributeAlias`; `attribute_of()`, `ATTRIBUTE_KINDS`, `ATTRIBUTE_RULES`; the one accessor and the registry of every kind's identity, display and storage is `services/attributes.py` (K2) |
| **quantity** | the attribute every variant has: how much is in the pack, with its unit; spoken as "size" for a millilitre quantity | 100 ml, 3.5 g, 6 pcs | `quantity_value`, `quantity_unit`, `form`; `quantity_ml` is the derived millilitre figure; `quantity_stated_value` / `quantity_stated_unit` the pair as first read |
| **picture level** | what a picture depicts: a brand mark, a product line's bottle, or one product variant; the row's own word, never the table it sits in; every reader shows the variant's picture, else the line's, else the brand's, and says which | a Glenfiddich mark (brand); the Glenfiddich 12 cut-out (line); the 12 at 1L with its travel sleeve (variant) | `image_level` on `brands`, `product_lines` and `product_variants`; `imagery.resolve()`, `Picture.level`; `image_tier()` for the featured selection (Stream AW3) |
| **listing** | one variant at one shop | the Blue Label 1L at Heathrow's World Duty Free | `listings` |
| **price** | what one listing showed at one moment | 130 USD on 12 Sep | `price_observations` |
| **shop** | one storefront we collect: its own catalogue, prices and currency | World Duty Free Heathrow; the Heinemann online catalogue | `shops`, `Shop`, `shop_id`, `shop_code`; the CLI's `shops` |
| **place** | where a shop is, of any kind; rian's word for it is "location" | Heathrow airport; Nassau cruise port; the Puttgarden border crossing | `places`, `Place`, `shop_places` (the refactor plan W19; K2) |
| **retailer** | the company that runs shops | Avolta; Gebr. Heinemann | `retailers` |
| **vertical** | the family a rule set is written for; a variant's vertical is its category's family, else the tree the collector walked | liquor, beauty, confectionery, tobacco | `product_variants.vertical` |
| **source** | one website we read, with its own kill switch, crawl delay and identity mode; one row per website, today one shop each | Iceland Duty Free; the Heinemann online catalogue | `sources` |
| **collector** | the code that reads one platform's websites; never a row | the Shopify collector (three Attenza shops); the Heinemann platform collector (three sources) | `collectors/<module>.py` |
| **decision** | a person's ruling over a value, or a person approving a proposed one; the one thing that survives every recomputation | a brand alias confirmed; a preferred product name typed at merge; a quantity corrected by hand | `decisions`, `Decision`; written only by `services/decisions/writer.py` (K2) |
| **proposal** | a proposed ruling over a value, waiting for a person to approve or correct it; the sheet's own row | an AI pass's read of "Rouge Allure" across seventeen derived lines | `proposals`, `Proposal`, `proposal_passes`, `ProposalPass` (K2; K4 builds the load, the sheet and the approval) |

Test: **if you can say the quantity, it is a product variant; if you would search for it, it is a
product line.** The value that tells two variants apart is always an **attribute**. A shop's own
word for a variant is stored as the **listed variant** (`listed_variant`) and never re-used in prose.
The URL `/products/` is not prose and keeps its settled form; it shows product lines.

**Who owns or makes it** (Puig, Coty, Mondelez, a distillery) is a different fact from the brand
and is not recorded yet. A row named for a maker is not a brand to alias; it is variants whose
brand needs re-reading, and until a variant-level brand decision exists it is left as it is.

## The layers

Every value at every level sits in one of three layers, decided 14 Sep:

| Layer | Test | Where |
|---|---|---|
| **listed** | the shop's own words, exactly as shown, never changed | `listings.listed_*` |
| **standard** | what the rules made of them; recomputable at any time by `rederive` | the variant's, line's and brand's own columns |
| **decided** | what a person set; survives every recomputation; a machine never overwrites it | one row in `decisions` per decision (append-only; `overrides` retired by K2), and the entity's column carrying the effective value |

"Standard" is a layer word only. The brand row every spelling resolves to is "the brand"; the
alias that points at it is a decided value, so the three layers apply to a brand unchanged
(listed brand: the shop's words; standard: the fold the rules made; decided: the alias a person
confirmed).

**A decision** is one `decisions` row (K2; `overrides` retired): an append-only ledger, a row
never updated or deleted, a later row on the same field superseding an earlier one by being
newer. A row carries the entity type and entity id (this host's read key), the natural key (the
portable address that survives a natural-key export and replay across hosts), the field, the
decided value, what the rules had for it (`rule_value`), who, when (`decided_by`/`decided_at`),
why (`reason`), its origin (a person's own act, or a person approving a proposal) and its batch
(the unit of approval, and of undo: an individual act is a batch of one). Every decision over a
VALUE at every level lives there: a variant's name, product line, attribute or quantity; a
brand's or line's alias, preferred name, hidden or indexed state; an attribute wording's
meaning; a listing's pin or ignore. A decision about a PAIR (Confirm same, Keep separate) is on
the suggestion row itself; a variant merge is a `merges` row carrying the ledger row's id back.
Undo is a row too, never a delete: it reverses the target and restores whatever was in force
before it, or releases to the rules when nothing was. Three records; one audit query joins them;
`audit_log` holds the history of changes.

## The operations

| Word | Test | What it does | Never |
|---|---|---|---|
| **suggestion** | a pair (two brands, two product lines or two variants) the rules suspect are one, waiting for a person | queued with a score and one readable `why`; the desk shows it as a **suggested match**; `suggestions`, `Suggestion`, `suggestion_id`; the `/collectors` tab **Suggestions** | a merge or an alias by itself |
| **Confirm same** / **Keep separate** | the person's answer to a suggestion, the same two verbs at every level | a pair decision is a `decisions` row on the suggestion itself (K2); Confirm same records it, then writes an alias (brands, product lines, wordings) or a merge (variants) as its consequence; Keep separate records it and the pair never resurfaces | called approve or reject (those words are not used on the desk) |
| **batch** | the unit of approval, and of undo; every decision belongs to one, an individual act a batch of one | a sheet approval; a desk Confirm ticked; a single route decision | `decision_batches`, `DecisionBatch` (K2); `suggestions.decision = same` / `separate`, the route `keep-separate` |
| **undo** | reversing one decision, or a whole batch, without deleting anything | restores what was in force before the reversed act, or releases to the rules when nothing was | `app.cli decisions undo-batch`; `services/decisions/undo.py` (K2) |
| **alias** | one row points at the row it stands for; both survive | a brand alias, a product line alias, an attribute-wording alias; readers follow the pointer | called a merge |
| **merge** | two variant rows become one; the loser forwards | listings and awards move; the loser keeps its row as a forwarding tombstone; `merges`, `Merge`, `merged_into_id` | applied to brands or product lines |
| **pin** | a person moves a listing to a variant and keeps it there | ingest honours it every sighting; `pinned_variant_id` | re-pinned by the rules |
| **ignore** | a person keeps a listing out of the site | prices keep collecting; the site's readers skip it | a delete |
| **review**: **checked** / **hidden** | a person's publish gate on a brand or product line page, named by its effect | `checked` is a stamp and changes nothing the site shows; `hidden` takes an eligible page off the site everywhere the one definition is read | applied to variants |
| **rederive** | recompute the standard layer from what was collected, no fetch | every variant's product line, key and attributes under the current rules | touching a decided value |
| **prune** | delete a derived row nothing references | an empty product line row the old rules made | a variant, a tombstone, a decision, an open suggestion |

## The display setting of an attribute kind

Every attribute a reader produces has a **kind** (concentration, color, flavor; quantity always
exists) and every such kind has a **display setting** (`product_lines.ATTRIBUTE_DISPLAY`,
`display_of()`), in the person's words:

| Setting | Test | Example | On a page |
|---|---|---|---|
| **picked** | changing it changes the price or what a shopper would search for | quantity (always), concentration | selectable; the comparison regenerates |
| **shown** | changing it changes neither | shade | information; never selected |
| **fact** | a fact about the variant the page neither picks nor lists | (no kind uses it yet; plan W8) | a line of facts |

The display question is asked only of a kind that is already an attribute; it never decides
whether a word is an attribute at all (that is the product line rule: whatever no reader lifts stays
in the product line). Age and vintage pass both tests and stay in the product line because no reader
lifts them; a marked tail of unknown kind (a tailed beauty row outside Makeup) is shown until a
person or a rule names its kind. A flavour or a colour may well be picked in a category food or
eyewear brings; the registry takes a per-vertical exception for that day.

## Words that were retired

- **house**: the brand row reached by following the aliases. Now simply **the brand**; in code
  `brand_of()`, and `brand_of` in SQL beside the stored row.
- **standard brand**: proposed for the same row and dropped, because the alias that reaches it
  is a decided value and "standard" is a layer word.
- **canonical** (for the pointer): now **alias of**, `alias_of_id`. The word survives only for a
  fold ("the canonical unit", an attribute wording's canonical form) and for SEO's canonical URL.
- **line** (in prose): now **product line**; in code the full form, never `line_id`.
- **product** meaning the barcoded thing: now **product variant**; in code `ProductVariant`.
- **variation**: now **attribute**, in prose and in code.
- **option**, **property**: now **attribute**.
- **determinant** / **descriptor**: now **picked** / **shown**.
- **location**: rian's word for a place. In prose say shop or place; in code `shops`.
- **merge candidate**: now **suggestion**. **Keep apart** / **reject**: now **Keep separate**.
- **the real product**: the phrase that first made "product" mean two things.
- **approve**: the desk's old button word for confirm; retired so a review state could not
  collide with it.

## Words reserved for later

- **owner**, **parent brand**: corporate ownership (Unilever owns Dove).
- **size**: apparel, if ever; spoken for a millilitre quantity, never a field.
