# Stream K9: splitting a brand the fold got wrong, and stopping the fold where it should propose

(written 2026-09-17 Thu from rian's questions in step D of `staging-deploy-and-review-2026-09-18.md`
and the issue `issue-the-brand-trailer-list-is-global-and-there-is-no-way-to`; runs in the
background while rian reviews on staging)

**Runs:** now, alongside rian's review pass. **It must not disturb that review**: never deploy,
never touch staging's database, never run a collection, never edit `.app.env` or `.env`. Everything
is rehearsed on `dfp-devdb` (127.0.0.1:5433) in a fresh copy of the newest staging dump
(`backups/dfp-2026-09-17-post-chain.dump`, the state the review starts from). Rian merges and
deploys when he is ready, which is after the review, probably Monday.

**Where:** its own branch off master in its own worktree, so nothing it does can reach the tree the
review is using.

```
cd /srv/apps/dutyfreeprofessor && git worktree add .claude/worktrees/brand-split -b claude/brand-split master
```

## Why

`normalize.brand_key` folds a brand spelling by dropping a trailing word from `_BRAND_TRAILERS`,
45 words applied to EVERY vertical: the drink categories, producer words (distillery, brewery,
vineyard, estate, winery, cellars), corporate suffixes (co, company, ltd, inc, sa, srl, gmbh,
brands), beauty words (parfums, parfum, fragrances, beauty, cosmetics), and paris, london, the.
It runs at ingest, when a listing's brand is resolved to a row. Two spellings never become two
rows: they compute one key and land on one brand. There is no decision behind it, so there is
nothing to undo, and `undo.unmerge` works on variants only.

On today's data every fold is right (Appleton Estate, Appleton Rum and Appleton; The Balvenie and
Balvenie). Two things are nonetheless wrong in principle, and rian named both:

1. **It acts where the model says an open list must propose.** It is the last such list.
2. **A wrong fold has no way back.** `rule:brand_trailers:6` writes one proposal per folded
   spelling asking a person to confirm it, but rejecting one records a rejection and moves no data.

The first bites the day a category beyond drinks and beauty is collected: a fashion brand named
"<name> London" or "<name> Paris", or two different companies differing only by a trailing listed
word, fold into one row silently. That is before the first clothing shop, not today.

## Goal

A person can split a brand the fold joined wrongly, as one recorded decision with an undo; a
rejected fold proposal offers that split instead of dying; the list stops acting where no vertical
owns it; and rian can see, in the review area, which lists exist, what words they hold and what
each one folded.

## What already exists, so you build on it

- `normalize.brand_key` and `_BRAND_TRAILERS` (the fold), `ingest.resolve_brand` (where it lands).
- The ledger: `services/decisions/` with `writer.batch`/`record`, the applier registry in
  `appliers.py` (`(brand, alias_of)` and `(brand, name)` are there; a split is a new field),
  `undo.py` (consequences first, `unmerge` as the compound precedent), `replay.py`, natural keys
  (`brand:<slug>`), and the append-only trigger.
- `merges.apply_brand_alias` for the opposite operation, including how it moves lines and variants
  and re-keys them; `publish.redirect_write` and the flattening; `keying.product_key`/`rederive`.
- The proposals store and the sheet (`services/proposals.py`, `/review`), where a rejection is
  recorded today; `rule_proposals`' `brand_trailers` generator writes the 91 rows.
- `REVIEW-PROCESS.md` section "Everything else is a proposal" names the lists but not their words.

## Tasks, in order (ids on /plan; commit prefix `K9:`)

1. **K9.1 Measure and pin the ground.** On a copy: how many brand rows hold more than one listed
   spelling, how many of those folds a listed word caused, and the same per vertical. Write the
   numbers into the brief's hand-back and a test fixture from the real rows (Appleton's four
   spellings, Balvenie's three). No code yet.
2. **K9.2 The split itself.** `services/brands.py` (or `merges.py` if it fits better, say which and
   why): `split_brand(db, brand, spellings, *, new_name, decided_by, batch=None)`. It mints the new
   brand row with its own uid and a slug from the chosen name (unique, `publish.final_slug`'s rules
   for brands), moves every live variant whose listed brand text is one of the named spellings,
   re-keys them through the same call ingest uses, re-points or re-derives their product lines, and
   prunes what is left empty. One decision row on the brand (`field = "split"`), whose `detail`
   carries every moved variant id with its old brand id and old line id, the spellings, and the new
   brand's uid, so the undo has everything it needs. Refuse: a spelling that is not on the row, a
   split that would empty the source, an aliased brand row (`ENTITY_ALIASED`), and refuse while a
   collection runs.
3. **K9.3 The undo.** `decisions undo` on a split moves the variants back by the detail, re-keys
   them, folds nothing, and deletes the minted brand row only when nothing references it (a merge
   tombstone, a decision, a redirect or a listing keeps it, as `prune_lines` does for lines). Pin
   the round trip: split, undo, and the database equals its start (row counts, keys, brand ids).
4. **K9.4 The reject path.** Rejecting a `rule:brand_trailers` proposal on the sheet stops being a
   dead end: it records the rejection as today AND queues the split as a suggestion the person can
   run (the spellings it would move are the ones the proposal named). Keep it one click from the
   sheet; never split automatically on a rejection.
5. **K9.5 Scope the list.** `_BRAND_TRAILERS` becomes per-vertical: the drink words for liquor, the
   beauty words for beauty, the corporate suffixes and articles everywhere, and NOTHING for a
   vertical nobody has written a list for (the neutral default the model already uses elsewhere).
   A fold in an unlisted vertical is a proposal, never an act. Existing drinks and beauty rows must
   not re-slug: prove it on the copy (`brand_key` unchanged for every brand row that exists).
6. **K9.6 Show the lists.** A panel in the review area listing every word list, its words, the
   vertical it applies to, and how many rows it folded or grouped, each linking to its proposals.
   Rian asked for exactly this: "do we have a list of those stop words somewhere maybe in the review
   dashboard so we know when we're doing that". Read-only; no new writes.
7. **K9.7 Rehearse and hand back.** On a fresh copy of the post-chain dump: split Appleton's "Appleton
   Rum" spelling into its own brand, check the pages, the key, the ledger and `decisions verify`;
   undo it; verify the copy equals its start. Then export the split and replay it onto a second
   copy by natural key (the ledger's own acceptance shape), because a split taken on staging must
   survive the push to production. `main/check.sh` green. Five-line handoff, numbers included.

## Must not touch

Staging and production (both databases and both deploys), `.app.env`, `.env`, the review pass's
files under `import/proposals/`, the identity boundary in `REVIEW-PROCESS.md` section 2 (K9.5
changes which vertical a list applies to, never what counts as certain), and anything under
`.logs/planning/staging-deploy-and-review-2026-09-18.md`, which rian is working through.

## Acceptance

- A brand rian splits on the sheet ends as two rows, each with its own page and address, every
  variant on the right one, keys re-derived, `decisions verify` 0 drift.
- The undo returns the database to its exact prior state, proven by counts and keys.
- No existing brand row's slug changes when the list is scoped.
- A fold in a vertical with no list is a proposal, and a test says so.
- The lists and their words are visible in the review area.
- `main/check.sh` prints "checks passed"; nothing deployed; staging untouched.
