---
type: plan
version: 1
status: the workflow rian follows (with an Opus session) from "K0 to K7 have handed back" to "staging holds a reviewed catalogue in the new shape"; written 2026-09-18 by the planning session from the seven handoffs, the running list and the RUNBOOK; nothing here has been run yet
---
# From the streams' hand-backs to a reviewed catalogue on staging

Read this whole file first. Then work it top to bottom with rian in the terminal; each step says what
it prints when it is right. Stop at the first line that does not, and say so rather than working around
it. The session that runs this must never run a collection, never touch production, never edit `.app.env`
or `.env`, and never approve a proposal or an index candidate itself: those are rian's acts.

## 1. What happened

Between Wed 16 Sep evening and Thu 17 Sep night, eight overnight streams (K0 to K7) rebuilt the catalogue
model on the branch `claude/table-layout-db-structure-1d5bc2`, 113 commits ahead of master and 0 behind,
tree clean at `97c7888`. K0 rebased the branch and wrote `REVIEW-PROCESS.md`. K1 renamed every table,
column, identifier and label to one word per concept (migration `c1d2e3f4a5b6`). K2 built the append-only
`decisions` ledger with its writer, undo, batch undo and natural-key replay, the `proposals` store, the
attribute registry and one accessor, `places` and `shop_places`, uids on every row a decision can name,
`hidden` and `indexed` as columns, and dropped `overrides` (migration `d2e3f4a5b6c7`). K3 replaced the
name-derived identity key with the certain-only key (identity rules v6), chose boundary (a) from a
three-way rehearsal (0 wrong joins against 114 comparisons losing a shop), carried shop options as
fields, and turned every hand-kept word list into a proposal generator. K4 built the review: a proposals
file per brand, the sheet at `/review`, approve, reject as Keep separate, undo, rehearsed end to end on
CHANEL and replayed onto a second copy. K5 built the product line page at `/products/<line-slug>` with
deep-linked variants and the five W20 price cases proven by URL. K6 made every page reachable and
`noindex, follow` by default, with index candidates a person approves. K7 wrote the production replace
script, its rehearsal (130,290 rows equal) and the 23-line launch checklist. Nothing is deployed;
staging is still 0.49.0 on master.

## 2. How it went

Every stream finished every task, ran `main/check.sh` green (1,702 tests at the end) and rehearsed on a
copy of staging with its numbers in the handoff; none deployed, migrated the live database or collected.
Three things need care. First, the streams ran in parallel on one tree, so each verified its own commit
on a clean export while another stream's uncommitted edits sat beside it; the final tree was checked
green by K6 last, but nobody has run the whole after-deploy chain (K1, K2, K3, K6 blocks) in order on one
copy since K3 handed back, which is what step C below does on staging. Second, the streams filed three
P1 issues against each other's code that were left for later because the owning stream had finished:
a confirmed alias never fixes a product line's slug on any live path (K6 found K3's hook on a path only
tests call); a shade decision loses its kind at the batch tail so `decisions verify` reports drift on
every approved shade (K4 found it in keying); and a sheet approval's decided key and mint live in the
approval rather than the applier, so replay does not carry them (matters after launch, not for the copy).
The first two bite the review you are about to do, so step A fixes them before the deploy. Third, the
streams took nine decisions under assumptions and filed each as a `decide` on the running list; none is
wrong on its face, but step D walks you through them because several are yours by the plan's rules.

## 3. The risks now

The largest risk is data, not code: the K3 block rederives every variant onto the v6 key and then folds
duplicates. On the copy it produced 5 merge groups, all right; on staging it must be read again
(`backfill merges --check`) before `backfill merges` runs, because a wrong automatic join is the one
error the constitution ranks above everything else. The second risk is time: the review is the part with
the least slack, one file per brand at about ten minutes of session time for a large brand, then your
spot-checks; you will not review 2,365 brands today, and the plan never expected you to (an unreviewed
brand stays on the certain-only grouping, reachable and noindex), so step F sets a scope you can finish.
The third is a deploy that kills a running collection; step C checks first. The fourth is search:
after the deploy every generated page is noindex until you approve it, so if the go-live flip follows
before any approvals, the sitemap will be empty of catalogue pages, which is the design, but you should
approve deliberately (step G) rather than discover it. Rollback at every step: staging's pre-deploy dump,
`downgrade -1` twice, the rules version, `decisions undo-batch`, `proposals withdraw --pass`.

## 4. The workflow

Work from the main workspace, `/srv/apps/dutyfreeprofessor`, except step A, which runs in the worktree
because the branch is checked out there.

### A. Fix the three P1 issues on the branch (about an hour; Fable or Opus)

Open a session in the worktree and paste:

```
cd /srv/apps/dutyfreeprofessor/.claude/worktrees/table-layout-db-structure-1d5bc2 && claude
```

> Fix three issues on the running list, each with the exact change already written in its detail
> (`python3 main/scripts/items.py list --all`, then read the item):
> 1. `issue-the-slug-hook-is-on-a-path-nothing-calls...` (K6): call `publish.slug_at_confirm` from the
>    `('product_line','alias_of')` applier's consequences in `services/decisions/appliers.py`, passing
>    `decision=row`; remove the two calls in `merges.py`; a test that the desk's Confirm same on two lines
>    writes a redirect row.
> 2. `issue-a-decided-shade-attribute-color-loses-its-kind...` (K4, keying.py `product_key`): the kind
>    comes from the decided row's field suffix, not the rule; the test the item names.
> 3. `issue-a-sheet-line-s-decided-key-and-its-mint-happen-in-the-approval...` (K2): move adopt-or-mint and
>    the `decided:` key stamp into the `(product_line, name)` applier so replay carries them; the test the
>    item names. If this one takes more than an hour, leave it and say so: it matters after launch, not today.
> One commit per issue, prefixed `K8:`. `main/check.sh` must print "checks passed". Mark each item done
> with `items.py`. Write a five-line handoff entry. Do not deploy, do not touch the live database.

When it hands back: `git log --oneline -4` shows the `K8:` commits and `git status` is clean.

### B. Merge, version, gates (fifteen minutes)

In the main workspace, on master:

```
cd /srv/apps/dutyfreeprofessor
git status                                   # clean; on master
git merge --ff-only claude/table-layout-db-structure-1d5bc2   # fast-forward: the branch is 0 behind
```

Bump the version: `main/app/version.py` to `0.50.0`; in `main/CHANGELOG.md` turn `## Unreleased` into
`## 0.50.0 — 2026-09-18 — the catalogue refactor: one term per concept, the decisions ledger, the certain
key, the review, product line pages, publish, the launch path`; the NOW block in `brief.md` names 0.50.0.
Then:

```
main/check.sh                 # prints "checks passed"
bash main/docs-check.sh       # prints 0 fail (warnings older than this work are fine)
git commit -am "0.50.0: the catalogue refactor merged for staging"
```

### C. Deploy staging and run the after-deploy chain (about half an hour of commands, most of it waiting)

```
ls -lt .logs/runs/ | head; pgrep -af app.cli            # nothing collecting; if something is, wait for it
docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp > backups/dfp-$(date +%F)-pre-refactor.dump
ls -la backups/dfp-$(date +%F)-pre-refactor.dump         # the rollback; not empty
srv-gw deploy --project dutyfreeprofessor --build
curl -s https://dutyfreeprofessor.demoing.info/api/health   # {"status":"ok","version":"0.50.0",...}
docker exec dutyfreeprofessor-app alembic current           # d2e3f4a5b6c7 (head)
```

The deploy runs both migrations. Then the blocks from `RUNBOOK.md` Migrations, in this order, one at a
time, reading each output (all through `docker exec dutyfreeprofessor-app python -m app.cli ...`):

1. **K1:** `backfill suggestion_decisions` (prints 0 rows: no decided pairs exist), `backfill
   attribute_keys` (about 2,800).
2. **K2:** `backfill places` (21 places; every shop a primary), `decisions verify` (`0 drift`).
3. **K3 (rules v6), instead of the v5 block:** `backfill options --check`, then `backfill options`,
   `backfill attributes`, `backfill lines`, `rederive` twice (the second prints zero everywhere),
   `backfill merges --check` and **read every MERGE line for a wrong join** (the copy gave 5 groups, all
   right; if any group joins two different products, stop and hide it with a Keep separate on `/collectors`
   before continuing), `backfill merges`, `suggest`, `backfill prune_lines`, `backfill rule_proposals`
   (about 900 proposal rows, nothing applied), `decisions verify` (`0 drift`).
4. **K6:** `index suggest --check`, `index suggest` (on the copy: 363 brands, 1,701 lines, 15 places),
   `index list --limit 20`.

Write the counts each command printed into `.logs/runs/deploy-staging-2026-09-18.md`. Then the second
dump, the state the review starts from: `backups/dfp-$(date +%F)-post-chain.dump`.

### D. The decisions the streams took under assumptions (half an hour of reading; yours to keep or overturn)

Run `python3 main/scripts/items.py list --decisions` and read these, each with its assumption and options.
The session should print each item's detail and let you answer; an answer that keeps the assumption is
`items.py add --update --id <id> ...` moving it to decided; an overturn becomes an issue for a follow-up
and does not block today.

1. `decide-a-stated-strength-against-a-silent-name-key-apart-built` (K3): a listing that states 43% keys
   apart from one that states nothing. Built as key apart (84 of the 699 split listings). Keep: the
   certain rule; a person confirms the pair. Overturn is a rules version v7.
2. `decide-brand-trailers-still-fold-inside-the-brand-slug` (K3): "Ltd", "& Co" still fold inside the
   brand key; undoing re-slugs 124 spellings. Keep for launch.
3. `decide-two-ingest-rules-k3-added-beyond-its-brief` (K3): the key honours a person's decisions, and a
   placed listing stays placed. Both are what keeps a reviewed variant from being minted twice at the
   next collection. Keep.
4. `decide-should-the-browse-grids-show-one-card-per-product-line` (K5): grids show one card per variant,
   each deep-linked. Your call; per line is a follow-up, not today.
5. `decide-should-a-product-line-membership-be-a-spot-check` (K4): memberships approve in bulk unless
   low-confidence or sampled. Keep for today's speed; it is one line to change.
6. `decide-the-floor-that-makes-an-airport-or-place-a-candidate` (K6): fifteen published variants. Keep.
7. `decide-a-brand-s-address-cannot-follow-the-chosen-name` (K6): a brand keeps its fold slug at confirm;
   only product line slugs follow the chosen name. Keep.
8. `decide-the-brands-index-still-lists-only-brands-over-the-floor` (K6): 364 brands on the index against
   1,498 reachable. Keep.
9. `decide-after-the-launch-which-machine-collects` (K7): production only; staging refreshes from it.
   Keep; it is checklist line 21.

Also read `decide-the-spot-check-thresholds-in-review-process-md-section-4` (K0's numbers) once, since
they decide how many rows you look at today.

### E. Tour the work (forty minutes; the session drives, you look)

All on `https://dutyfreeprofessor.demoing.info`, signed in as rian. The session finds real slugs on
staging first (K5's handoff URLs were from a copy; after the rederive some slugs differ):

```
docker exec dutyfreeprofessor-db psql -U dfp -d dfp -tAc "select slug from product_lines where not hidden order by id limit 5"
```

1. **A product line page.** `/products/<slug>` for a Johnnie Walker or Ballantine's line: the variant
   selector, the price block asking for a variant, then `?variant=<id>`, then `&airports=LHR,CDG`. Every
   price carries its date; "From $X" never appears. View source: `noindex, follow`, the bare canonical, the
   `ProductGroup` JSON-LD with dated offers.
2. **A card's deep link.** From `/savings` or an airport page, click a comparable card: it opens the line
   with the variant and airports its comparison used, and the same numbers.
3. **An old address.** `curl -sI https://dutyfreeprofessor.demoing.info/products/<old-name>-<variant id>`
   prints 301 to the line.
4. **The words.** `/collectors`: the tabs say Suggestions and Product variants; the buttons Confirm same
   and Keep separate; the Listings table says Brand and listed brand. `main/docs/VOCABULARY.md`'s code
   column is empty.
5. **The review area.** `/review`: the sheet list is empty of session passes but shows the rule passes
   (`rule:<list>:6`) and `arrival:6`; open one brand's sheet to see the shape: rows with proposed, rule and
   current values, evidence spans, spot-check marks. The Index candidates panel below lists the pages over
   their rule.
6. **The ledger.** `docker exec dutyfreeprofessor-app python -m app.cli decisions list --since 2026-09-18`
   prints the batches the chain wrote (the K3 fold's tail and nothing else); `decisions verify` prints
   `0 drift`.
7. **An airport page.** `/airports/LHR`: the counts equal yesterday's (one place per shop today).
8. **The launch script's dry run**, so you have seen it before Monday: `deploy/production.sh --help` and
   read `RUNBOOK.md` "Launch checklist (staging to production)" once.

### F. The review pass (the rest of the day; this is the human review)

**Scope.** Not every brand. `REVIEW-PROCESS.md` §5 orders brands by comparison gain, then the 97 brands
that hold 53 percent of listings, then the rest. Today: **the top 30 by comparison gain**, which is where
the barcode-less variants that could join a cross-airport comparison sit. Every unreviewed brand stays on
the certain-only grouping, reachable and noindex, which is the plan's designed partial state.

**Who does what.** A Claude session writes each brand's proposals file and loads it (`/review-pass`, the
command written for this, `.claude/commands/review-pass.md`); it never approves. You approve on
`/review`: read the spot-check rows (they lead the sheet), approve or reject each, then **Approve all
except spot-checks**. About five minutes a brand once you have the rhythm.

**Run it.** Three terminals at most, each on a slice of the ranked list so they never touch one brand
twice:

```
cd /srv/apps/dutyfreeprofessor && claude
/review-pass 1-10          # then 11-20 and 21-30 in the other two
```

The command ranks the brands on staging, and for each: reads the brand's variants, listings and listed
words with read-only SQL; writes `import/proposals/2026-09-18-<brand>.json` per §6 (natural keys only,
a span per value, empty beats guessed, the §2 grouping defaults); validates; `proposals load --check`;
`proposals load --as rian`; reports rows loaded, resolved, parked, spot-checks; moves to the next brand.
When the first file lands, open `/review`, take that brand's sheet, and keep pace with the sessions.

**Per brand on `/review`:**
1. Spot-check rows first: read the listed words beside each; approve or reject (a reject asks for one
   sentence; a rejected pair is a Keep separate).
2. Approve all except spot-checks. The banner says "N approved, M held, R refused". A refusal named
   `LINE_NOT_APPROVED` means a header was a spot-check you have not acted on yet; act on it and approve
   the line.
3. If a sheet looks wrong as a whole, `Undo` its batch below the sheet and tell the session, which
   withdraws the pass (`proposals withdraw --pass <name>`) and rewrites the file.

**After each ten brands** the session runs `decisions verify` (`0 drift`), `rederive` (zero), and
`backfill merges --check` (read it: an approved membership can make two variants share a key; a fold
between a decided variant and another is refused by design and appears as a suggestion instead).

**Also today, if there is time:** the rule passes (`rule:<list>:6`, about 900 rows fleet-wide) sit on
the same sheets, joined by brand; they are what the retired word lists would have done. Approve them
brand by brand as you go, never fleet-wide in one act.

### G. Index approvals (twenty minutes)

Once the reviewed brands are approved, decide what search engines see. On `/review`, Index candidates:
approve the reviewed brands, their product lines that have two or more comparison units, and the
airports. Or in one line per kind, when you are content with the rule's list:

```
docker exec dutyfreeprofessor-app python -m app.cli index list --limit 50
docker exec dutyfreeprofessor-app python -m app.cli index approve brand:<slug> --by rian      # per page
docker exec dutyfreeprofessor-app python -m app.cli index approve --all-suggested --by rian   # everything over the rule
```

Staging has no IndexNow key, so approvals ping nothing there. `curl -s https://dutyfreeprofessor.demoing.info/sitemap.xml
| grep -c '/products/'` equals the count of indexed, unhidden product lines.

### H. The end state, checked

```
docker exec dutyfreeprofessor-app python -m app.cli decisions verify        # 0 drift
docker exec dutyfreeprofessor-app python -m app.cli rederive                # zero everywhere
docker exec dutyfreeprofessor-app python -m app.cli backfill merges --check # read; nothing wrong
docker exec dutyfreeprofessor-app python -m app.cli proposals sheet         # the reviewed brands show no waiting rows
docker exec dutyfreeprofessor-db pg_dump -U dfp -Fc dfp > backups/dfp-$(date +%F)-reviewed.dump
```

That dump is the catalogue in the new shape: every variant on its certain key or its decided line, every
decision in the ledger with who and why, every reviewed brand's pages indexable, everything else reachable
and noindex, and the next collection landing new listings by barcode or certain key and queueing
anything else as an arrival proposal on the sheet. Write the day's numbers to a handoff entry.

### I. What is left for Monday, and what is not today's

- The launch itself: `RUNBOOK.md` "Launch checklist (staging to production)" from line 1, on Sunday
  night or Monday. Lines 1 to 8 are what today did; line 9 onward is the replace.
- Before the flip: `INDEXNOW_KEY` in production's `.app.env` (your to-do), the unauthenticated route sweep.
- Not today: the cell editor above the line (unless the sheets argued for it), one card per product line
  in the grids, the P1 on proposal files carrying row ids (the new files use natural keys, so it is closed
  in practice; the two 15 Sep files must simply never be filed on production), the two K2 replay issues if
  step A left the third one.
- The review continues after launch, brand by brand in value order, with the same command; a collection
  between reviews adds arrival proposals, never merges.
