# Rehearsal: the brand split, its undo and its replay (Stream K9, 17 Sep)

Where: `dfp-devdb` (127.0.0.1:5433) only. Staging and production were never contacted; no
collection ran; nothing was deployed. Code: `claude/brand-split` in its own worktree.

## Inputs
Four databases, each a fresh restore of `backups/dfp-2026-09-17-post-chain.dump` (25.8 MB, the
state rian's review starts from): **2,365 brands, 13,879 product lines, 17,373 product variants
(16,756 live), 23,771 listings, 0 decisions**.

- `dfp_k9` the first split and the reject path · `dfp_k9b` the scoping measurements ·
  `dfp_k9c` the split-check-undo round trip and the export · `dfp_k9d` the replay target.

The state fingerprints compared throughout are three md5 hashes over the whole catalogue: every
brand as `slug:name:alias_of`, every product line as `brand:key:slug:name`, every product variant
as `id:listed brand:brand slug:line key:match key`.

## K9.1 the ground (`dfp_k9`)
163 brand rows hold more than one listed spelling; **23** of them hold them only because a
trailing listed word was dropped (450 live variants, 52 distinct spellings): **liquor 19 rows /
180 variants, beauty 4 / 270**, no other vertical. The words that actually fold: rum 4, gin 4,
estate 3, tequila 3, beauty 2, then whiskey, distillery, estates, wines, spirits, winery, london,
cosmetics once each; **32 of the 45 listed words fold nothing at all**. Largest: Jo Malone 112
variants, Gucci 82, Prada 65, 1800 28, Don Julio 26; Appleton's four spellings, 10.
`rule:brand_trailers:6` holds 91 proposals -- more than 23, because it also reports a row whose
every spelling carries the same trailer, where nothing was joined.

## K9.2 / K9.3 the split and its undo (`dfp_k9c`)
`brands split --brand appleton --spelling "Appleton Rum" --name "Appleton Rum" --by rian`:

| | start | after the split | after the undo |
|---|---|---|---|
| brands | 2,365 | 2,366 | **2,365** |
| product lines | 13,879 | 13,879 | **13,879** |
| product variants / live | 17,373 / 16,756 | same | same |
| decisions | 0 | 1 | 2 (the split and its reversal) |
| the three fingerprints | — | all three changed | **all three identical to the start** |

- 4 product variants moved, 4 product lines created under the new row, 4 pruned off the old one.
- keys re-derived: `appleton|estate-12yo|abv=43|1000ml` became `appleton-rum|estate-12yo|abv=43|1000ml`,
  and the same for the 8yo, the 15yo and the Signature.
- pages: `/brands/appleton` kept its name, 5 entries, 5 variants, 4 airports, and went from 10 to
  6 product line rows; `/brands/appleton-rum` went from 404 to 200 with 4 product line pages of
  its own; after the undo it is a 404 again and `appleton` is back to 10 lines.
- the ledger: one row, `brand:19 split` by rian, individual, with the spellings and the slug in
  its value; `decisions verify` **0 drift** at every step.

## K9.4 the reject path (`dfp_k9`)
`appleton`'s `rule:brand_trailers:6` proposal rejected -> "0 approved, 0 held, 0 refused, 1
rejected"; the sheet's rejected row came back carrying the offer: 'Appleton Estate' (4 variants,
folded on "estate"), 'Appleton Rum' (4, "rum"), 'Appleton Estate(R)' (1, "estate") and 'Appleton'
(1, not folded). Running it on 'Appleton Rum' moved 4 variants, created 4 lines and pruned 4,
leaving appleton 6 live variants and appleton-rum 4; `decisions verify` 0 drift.

## K9.5 the scoping (`dfp_k9b`)
2,365 brand rows with a live variant (liquor 2,108, beauty 217, confectionery 40):

- **0 whose slug changes** under the scoped list;
- 2,681 listed spelling-and-vertical pairs re-resolved through `ingest.resolve_brand`: **0 land on
  a different row**;
- 16,756 live variants re-keyed: **0 keys move**. So no rederive follows this deploy and identity
  rules stay at v6.

The measurement found the one thing that would have moved: scoping alone would have re-homed
'Souvenir de Paris' (62 live variants on `souvenir-de`), 'Distillerie De Paris' (3) and 'Huda
Beauty' (13) at the next collection -- all classified liquor, so beauty's "paris" and "beauty"
stopped reaching them -- minting three brand rows beside 78 existing variants with no decision
behind it. Hence `Maps.placed_spellings`: a list change decides where an unseen spelling goes and
never moves one that is already placed.

## K9.6 the word lists (`dfp_k9b`)
Seventeen lists on `/review`, read from that database: brand trailers liquor 29 words / 19 rows
folded, beauty 7 / 4, every-vertical 9 / 0 (23 in total, the K9.1 number); drink words 41 words,
188 proposals waiting on 92 brands; format words 63 / 257 / 117; cask words 8 / 154 / 80;
lists-together 159 / 80; connectors 29 / 37 / 24; region words 21 / 14 / 11; brand partial 12 / 7;
shade shapes 10 / 5; audience 19 / 4 / 3; noise 3 / 3; age words 1 / 1; skin-type tails 0.

## K9.7 the replay (`dfp_k9c` -> `dfp_k9d`)
`decisions export --batch <uid>` wrote 1 decision and 1 batch, carrying `brand:appleton`, the
field `split` and the whole claim in its value (`brand_slug`, `name`, `spellings`) and no local id.
`decisions replay --file split.jsonl --label staging` on the second copy: `--check` first (1
applied, nothing written, brands still 2,365, decisions still 0), then for real: **1 applied, 0
already, 0 superseded, 0 held, 0 parked**. The target minted `appleton-rum`, moved the same 4
variants, re-derived the same four keys and lines, kept the source's decision uid and stamped
`replayed_from staging@2026-09-17-2156`; `decisions verify` **0 drift**.

That the target works the move out for ITSELF rather than trusting the file is pinned by a test
rather than by these two copies of one dump: an export whose `moved` detail is replaced with
`[[9999, 9999, 9999]]` still replays correctly, and the applied row's detail holds the target's
own ids (`tests/test_brand_split.py`, `TestItSurvivesTheReplayToAnotherHost`).

## Found by rehearsing, and fixed
1. `match_key` re-folded the brand ROW's own words, so every moved variant keyed straight back
   under `appleton|`. The brand slot now takes the row's words as given.
2. `ingest._has_table` asked `inspect(bind)`, which borrows a second connection; on a
   single-connection pool that is the session's, and releasing it rolled back the uncommitted
   split. It asks through `db.connection()` now.
3. `writer.record` raised `TypeError` on a naive `decided_at` (a replayed timestamp from a
   database that stores no zone). It reads it as UTC.
4. `LINE_HOUSES_DIFFER` fired during an undo, where the reversal is a restore rather than a
   person's new choice; it no longer applies to a reversal.
