You are running **Stream N** (the night of 10 Sep) of the Duty Free Professor build, as an
autonomous session on Fable: you orchestrate, decide and review; sonnet subagents do the
mechanical work you specify, and you verify every diff they hand back. Start by running `pwd`;
it must be `/srv/apps/dutyfreeprofessor`, or stop.

This command is its own brief. Read first, in this order: `.logs/planning/streams/OVERNIGHT-RULES.md`
(binding), then `agents.md`, `brief.md`, build plan `.logs/planning/build-plan-2026-09.md` sections
2, 4 (item 9 especially), 7, 9 and 10, `.logs/decisions-for-rian.md` (generated; read, never edit),
the two newest entries of `.logs/handoff.md` (dated 2026-09-10 and the first 2026-09-09; stop there),
`main/docs/ARCHITECTURE.md`, and `python3 main/scripts/items.py list` in full.

You are the only session in this working tree tonight, so every file is yours. The rest of the
overnight rules still bind: never deploy, never run a collection, never run a migration against the
live database, never `git push`, never touch `.env`, `.app.env` or `.bw-auth.env`. Rian stands
production up tomorrow (10 Sep) on the launch domain behind a domain password; nothing you change is
visible until he deploys, and everything you land must be deployable as one unit with what Stream S
left green (six `S:` commits, `backfill perfume` to run after deploy).

## Why this session exists

Two asks from rian on 9 Sep: make every safe piece of progress that needs no deploy, no network,
no live database and no decision from him; and make sure every document is current, with no stale
statement anywhere and a complete log of issues. The second ask is not optional and not last: it is
task N2, and it runs whether or not N1 finishes.

## How this session records progress

Add a lane for yourself to `import/progress.json` once, at the start, under the same lock
`plan-set.py` uses, and never edit that file by hand again (use `plan-set.py` for status):

```bash
python3 - <<'EOF'
import fcntl, json
p = "import/progress.json"
with open(p, "r+") as f:
    fcntl.flock(f, fcntl.LOCK_EX)
    d = json.load(f)
    if not any(s["key"] == "N" for s in d["streams"]):
        d["streams"].append({"key": "N", "title": "N · Night of 10 Sep", "session": "/stream-n", "tasks": [
            {"id": "N1", "wave": "w2", "title": "O1: default-deny auth middleware, the mutating-route inventory as its allowlist, an owner login for the routes behind it", "status": "todo", "paid": False, "due": "2026-09-10"},
            {"id": "N2", "wave": "w2", "title": "Every document current: main/docs against their sources, tier-1 files, stream briefs, plan statuses; the issue log complete", "status": "todo", "paid": False, "due": "2026-09-10"},
            {"id": "N3", "wave": "w3", "title": "Awards: a shorter competition entry no longer lands on the longer retail variant; medal artwork gaps", "status": "todo", "paid": False, "due": "2026-09-10"},
            {"id": "N4", "wave": "w2", "title": "Home page: the hero's airport picker no longer shifts the card when its list arrives", "status": "todo", "paid": False, "due": "2026-09-10"},
            {"id": "N5", "wave": "w4", "title": "Review pass: every route against a restored dump, house style in shopper-facing text, doc gates strict, obvious fixes landed, the rest filed", "status": "todo", "paid": False, "due": "2026-09-10"},
        ]})
        f.seek(0); f.truncate(); json.dump(d, f, indent=1); f.write("\n")
EOF
```

Then the overnight rules as written: `plan-set.py N1 doing "Stream N"` when you start a task, `done`
with a `--note` that says what proves it, `blocked` with why. Commit prefix is **`N:`**, one commit
per landed change. A CHANGELOG line under `## Unreleased` for anything rian, Adam, Mark or a shopper
would notice; never bump the version. Run `../.venv-dev/bin/pytest tests -q` and `main/check.sh`
before every commit. Every `items.py add` and `resolve` carries `--by "Stream N"`.

**Delegation.** You design, decide and review. Hand a sonnet subagent anything with a precise spec
and an acceptance test: test updates, docstring passes, bulk substitutions, reading a set of files
and reporting stale statements against a named source. Verify its output yourself (run the tests,
read the diff) before you commit. Never delegate the middleware's design or allowlist, anything in
identity or matching rules, any client-facing wording, or any judgment about whether a document is
stale.

**Rehearsal database.** `dfp-devdb` (`127.0.0.1:5433`) may or may not still exist (item `do-dfp-devdb`
is rian's). If it does: restore the newest `backups/dfp-nightly-*.dump` into a fresh `dfp_n` on it
(`docker exec dfp-devdb psql -U dfp -d postgres -c "create database dfp_n"`, then
`docker exec -i dfp-devdb pg_restore -U dfp -d dfp_n --no-owner --no-privileges < backups/<file>`;
`DROP DATABASE` cannot run inside a transaction, so drop and create are separate commands), point
`DATABASE_URL` there (the scratch container's password is in its environment; never print it), and
drop `dfp_n` when you finish. Stream S's recipe for a local server: build the SPA (`npm run build` in
`main/web/`), `ln -sfn web/dist main/static` (gitignored; without it the page routes are not mounted),
`../.venv-dev/bin/uvicorn app.main:app --port 8765` from `main/` with `PUBLIC_BASE_URL` set, curl,
then remove the symlink. Kill the server by port, never with a `pkill -f` pattern that matches your
own shell. If the container is gone, verify with the route tests alone and say so. Never point
anything at the live database.

## The tasks, in order

### N1. O1: the default-deny auth middleware (build plan section 4 item 9; plan task O1)

The plan's own words: "an ASGI middleware that denies any non-GET request and any `/owner*` or
`/api/owner*` path without a valid session, except exact paths on an initially-empty
`PUBLIC_MUTATIONS` allowlist; a test walks `app.routes` and fails if a mutating route is neither
allowlisted nor covered, run by `check.sh`; the kill switch is the first route behind it. Session
cookie Secure/HttpOnly/SameSite=Lax, Origin check on mutations, argon2, rate limit and lockout on
login." Rian ranked it first because it closes the open write routes before production exists on a
public domain, and because comments being editable by anyone past the shared password (9 Sep
handoff, deliberately open) closes with it.

What exists: `tests/test_route_inventory.py` and `tests/fixtures/mutating_routes.json` list the
eighteen mutating routes; `accounts` (migration #1) has no password column; no argon2 or session
library is installed; `.app.env` holds the environment and you may not touch it. Stream R (the real
owner login, migration #4) may run after 18 Sep. So this is the stopgap that the plan asked for
first, built inside these constraints:

- **Default deny.** Every non-GET/HEAD/OPTIONS request is refused (403, JSON body with an
  `error_code`) unless its exact route is on the allowlist or the request carries a valid owner
  session. Any `/owner*` or `/api/owner*` path is refused without the session regardless of method.
- **The allowlist is the inventory, split.** Rewrite `mutating_routes.json` (or a sibling the test
  reads) so every mutating route is declared as either `public` with a one-line reason (the client
  surfaces Adam and Mark use behind the shared password: comments and comment edits, priorities,
  quote selections and requests, to-do complete/reopen/uploads, subscribers) or `owner` (the kill
  switch `POST /api/sources/{slug}/enabled`, every `/api/items/*` action, `POST /api/discussion`
  and `/resolved`, and anything else only rian should do). The inventory test fails on a mutating
  route in neither list, and a second test asserts the middleware actually refuses each `owner`
  route without a session and admits each `public` one; both run in `check.sh` through the test
  suite. Nothing Adam or Mark can do today on the demo host may stop working.
- **The owner session, without a migration or a new dependency.** Default design, unless you find a
  better one inside the same constraints (then record the deviation as a `decide`): an owner
  credential `OWNER_PASSWORD` and a `SESSION_SECRET` read from the environment
  (`app/config.py`; both absent by default, and when either is absent every `owner` route is refused,
  fail closed, with a startup warning naming what to set); `POST /api/owner/login` compares in
  constant time (`hmac.compare_digest`), is rate-limited per client IP with a lockout, and sets a
  signed cookie (`hmac` over an expiry, stdlib only) that is `Secure`, `HttpOnly`, `SameSite=Lax`,
  scoped to `/`; `POST /api/owner/logout` clears it; every mutation, public or owner, checks the
  `Origin` (or `Referer`) header against `PUBLIC_BASE_URL` or the request host and refuses a
  mismatch. Stage the two lines in `.app.env.example` (create it if absent) as
  `OWNER_PASSWORD=REPLACE_WITH_PASSWORD` and `SESSION_SECRET=REPLACE_WITH_SECRET`; never write
  `.app.env`, never print or invent a value, never ask rian to paste one in chat. The RUNBOOK says
  how he fills them (his own shell, `chmod 600`, then recreate the container).
- **The SPA side.** The owner controls that call `owner` routes (the `/sources` toggle, the running
  list's decide/done/dismiss/reopen on `/plan` and `/issues`, curator add and resolve on `/discuss`)
  get one small login form (password only) that posts to the login route, and show a plain "sign in
  to do this" state when the cookie is absent or the server answers 403. No token in `localStorage`.
- **Tests, house form:** the inventory split; the middleware refusing and admitting; the Origin
  check; login rate limit and lockout; fail-closed when the environment is unset; the cookie flags.
  `tests/test_site_routes.py` and `tests/test_feature_comment_edit.py` must still pass without
  changes to their intent.
- **Docs:** `main/docs/ARCHITECTURE.md` gains the request path through the middleware;
  `main/docs/RUNBOOK.md` the two environment lines and how rian sets them; `CLIENT-SURFACES.md`'s
  positioning rules say which surfaces are public and which need the owner session; the 9 Sep
  handoff's "deliberately open" sentence is answered in your handoff. `agents.md` gets one line
  under "Decided 2026-09-04, pointers only" replacing "(§9 #2; not yet built)" on the auth line with
  the stopgap's shape and the pointer, within its 140-line budget.
- **Record:** one `decide` for rian (`--by "Stream N" --weight costly --blocks E4b`): the stopgap's
  shape, that owner mutations are refused until he sets the two lines and redeploys, and that Stream
  R replaces it with accounts; assumption "stopgap ships; R replaces". Resolve nothing of R's.

If the design cannot be made safe inside the constraints (for example the Origin check would break
a client surface you cannot test), land the inventory split and the middleware for `owner` routes
only, mark N1 `blocked` with the reason, and file the decide.

### N2. Every document current, and the issue log complete

Rian's words: "make sure all our documents are very up to date, no stale info, complete log of
issues". Budget about two and a half hours; record in the handoff what you did not reach. The
standard is the one `agents.md` states: docs are gated, not remembered; one home per mechanism;
state, not summaries; never type a number a generated block or `/api/stats` can supply. This is a
review of what the documents *say* against what the code and the trackers *are*; a subagent can
list candidate stale statements file by file, but whether each one is stale is your call.

1. **`main/docs/*.md` against their sources of truth.** Each file names its sources at the top.
   For each, read the sources (or have a subagent report every claim in the doc that names a
   function, file, route, flag, count or "today" state, with the line in the source that confirms
   or contradicts it), then fix the doc: wrong statements corrected, retired mechanisms removed
   (keep the rule, drop the obituary), new mechanisms since 5 Sep added where the doc is their home
   (the airport address table, `PriceOut.location_path`, the brand floor, the price of record, the
   Perfume backfill, the middleware). Run `python3 main/scripts/docmap.py --write` first so generated
   blocks are fresh, and `bash main/docs-check.sh --strict` after: every warn that names a doc or a
   source is yours tonight, and the handoff names any you leave.
2. **Tier-1 files.** `agents.md`: every rule still true, every pointer resolves (item
   `issue-agents-robots-pointer` says the robots rule still points at the Heinemann collector; fix
   and resolve it), budget 140 lines. `brief.md`: only the NOW block changes, at the checkpoint.
3. **Stream briefs** (`.logs/planning/streams/*.md`) and `KICKOFFS.md`: each brief that is finished
   or superseded gets one dated line at the top saying so and pointing at the handoff or commit;
   do not rewrite their bodies (they are the record of what was asked). `KICKOFFS.md`'s table lists
   every `/stream-*` command that exists, including `/stream-s` and this one.
4. **The build plan** is a dated document; it is not rewritten. Only the "amended" convention
   already used in section 9 may be extended, and only for a decision rian has actually made
   (read `.logs/decisions-for-rian.md` "Decided or done").
5. **`/plan` statuses.** Every task in `import/progress.json` whose status is a lie gets corrected
   with `plan-set.py` and a note: a `doing` task with no session on it (R4 has said `doing` since
   9 Sep while the Singapore retry waits on rian's decide) becomes `blocked` with the reason; a
   `blocked` note that names something since delivered is updated. Never touch `due`, `rian` or the
   `quote` block.
6. **The issue log is complete.** Sweep these sources and file every defect, gap or decision that is
   not already on the running list (`items.py add --by "Stream N"`, with `--owner`, `--priority`,
   `--blocks` and `--weight` where they apply), resolve every item that is in fact done (with the
   commit as the note), and merge duplicates (resolve the duplicate with a note naming the survivor):
   every `.logs/handoff.md` entry's "Blocked", "In flight", "Next" and "needs rian" lines (91
   entries; a subagent lists the candidates, you judge); `main/CHANGELOG.md` "After deploy" lines
   against what 0.33.0 deployed; `grep -rn "TODO\|FIXME\|xfail\|skip(" main/app main/web/src
   main/tests`; the tails of `.logs/runs/*.log` for errors nobody filed; `docs-check.sh --strict`
   output; `.memory/*` for facts that are no longer true (delete or correct). Then
   `python3 main/scripts/items.py export` and confirm `.logs/issues.md` and
   `.logs/decisions-for-rian.md` regenerated. Never edit those two by hand.
7. **`main/CHANGELOG.md`**: the Unreleased section reads as one coherent list for the deploy rian
   makes tomorrow, in the order a reader needs (the after-deploy commands together at the end).

Commit as `N: docs current and issue log swept` (one commit, or one per document if large), with a
body that lists what was stale and what was filed.

### N3. Two awards issues that are pure logic (owner F)

Read both items in full first (`python3 main/scripts/items.py list`, ids
`issue-award-shorter-entry-on-variant` and `issue-medal-artwork-missing`), then
`main/docs/QUALITY.md` and `notes/awards-strategy-for-adam-2026-09-05.md`, then the matcher
(`app/services/awards_import.py` and its tests). The rules that bind: medal matching is
precision-first, a medal on the wrong bottle is worse than a medal missing, each matcher owns its
token rules, a tie between different names is no match, and any change is followed by a review of
samples.

1. **The shorter entry on the longer variant** (Bacardi Ocho landing on the Rye Cask Finish). Fix
   the matcher so a competition entry whose name is a strict prefix or subset of a retail name
   with extra expression words does not match that variant when a plainer product exists, and does
   not match at all when only the variant exists (missing beats wrong). Write the test from the
   real record in the docstring. Rehearse on `dfp_n` with `awards --rebuild` (read the CLI's own
   help first), record before and after counts (matched, created, removed, ambiguous) in the commit
   body, and read twenty of the changed matches by hand; if more than a handful look wrong, revert
   and file what you learned instead. The rebuild runs only on `dfp_n`, never live; the CHANGELOG
   line says "After deploy: `awards --rebuild`" only if the fix needs it.
2. **Medal artwork for four wine competitions.** Find how medal assets are produced (`/medals`,
   `Medal.tsx`, any script). If silver and bronze can be derived mechanically the way the existing
   set was (a script, a template), do it and pin the asset names in the test that lists them. If it
   needs design work, leave the item open with a note saying exactly which files are needed and
   move on; no invented artwork.

### N4. The hero's airport picker shifts the card (home CLS 0.025 on a phone)

Item `issue-the-hero-s-airport-picker-shifts-the-card-when-its-list`. Reproduce first: local server
against `dfp_n`, Lighthouse mobile (`npx lighthouse` or headless Chrome with the CLS trace) on `/`,
record the number. Fix in the SPA (reserve the list's height, or render the shell of the picker at
its final size before the airports arrive), re-measure, and put both numbers in the commit body and
the CHANGELOG line. `main/docs/SEO.md` keeps its performance paragraph current. If the scratch
database is gone, the measurement can still run against the demo host's own API through the local
server only if you can do so read-only; otherwise mark N4 `blocked` with "no database to measure
against" and leave the item open with the diagnosis.

### N5. The review pass: what we built so far, with obvious fixes landed

Scoped, not open-ended. Three sweeps, each producing commits for obvious fixes and running-list
items (`--by "Stream N"`, with an owner) for everything else. "Obvious" means a one-file fix whose
correctness you can pin with a test or a curl; anything that needs a design choice is an item.

1. **Every route, for real.** Against the local server on `dfp_n`: every path `App.tsx` declares
   (with a real id, slug or airport where it takes one), every entry `sitemap.xml` lists (sample
   fifty products, every airport and brand), `robots.txt`, `feed.xml`, `llms.txt`, `/data`, every
   `/api/*` GET the SPA calls (read `web/src/api/queries.ts`). Record status, size and any 5xx or
   traceback in the server log; open the six main pages in headless Chrome and read the console for
   errors. A 5xx or a blank page is a defect; a 404 where the SPA would render is a defect
   (`agents.md`: a catch-all route makes a missing route look like a working one).
2. **House style in everything a shopper or the client reads.** No em dashes: `grep -rn "—"
   main/web/src main/app/services/seo.py main/app/services/feeds.py main/web/index.html`; tonight's
   S session left `DEFAULT_TITLE` and the page fallbacks (`AirportPage.tsx`, `ProductPage.tsx`,
   `BrandPage.tsx`), `BrowsePage.tsx`, `CollectionPage.tsx`, `SavingsPage.tsx` and
   `ExclusivesPage.tsx` for this pass; change them as one commit with the `index.html` title and
   pin the absence in `tests/test_seo.py`. Never "cheap" or "free" in client-facing copy (the
   storefront may say "cheapest": it is the comparison; the client surfaces may not say "cheap");
   nothing implying the code is the client's. A price is an observation: any copy that says "the
   price" as a fact gets a date or the word "seen".
3. **The gates, strict.** `bash main/docs-check.sh --strict` must print zero fails; warns that name
   something you or Stream S changed are fixed, the rest are listed in the handoff. `check.sh` green.
   Then Lighthouse mobile on `/`, one product page, one airport page and one brand page: record the
   four scores and CLS in the handoff, fix what is one file (an unsized image, a missing
   `width`/`height`, a font without a fallback), file the rest.

## What you never do in this session
- Deploy, or run `srv-gw deploy`. Say "green and ready" in the handoff.
- Touch the WP Engine placeholder, Cloudflare, DNS, the gate, the production host, or anything
  Stream E owns; the E issue on the running list is rian's reading for tomorrow.
- Register an app client, change gating, or add a hostname to the app container.
- Write `.app.env`, `.env` or `.bw-auth.env`, print a secret, or ask rian to paste one.
- Run a collection, a crawl, or a migration against the live database; write a new migration at all
  (the migration token is rian's, and nothing tonight needs one).
- Build B6 (category pages), Stream R's accounts, brand-and-airport pages, price charts, or
  terminals. All are gated; the structure page and the running list record them.
- Post, edit or delete a comment on any client surface, as anyone; change any `/todo` row.
- Rename a thread key, a `/todo` title, a running-list id, or a plan task id.
- Edit `.logs/issues.md`, `.logs/decisions-for-rian.md` (generated), or
  `.logs/planning/site-structure-proposal.md`; rewrite the build plan or a stream brief's body.

## Goal, and exactly when you are done

**Goal:** the auth middleware is landed to the verified-and-committed standard or blocked with its
decide filed; every document says what the code and trackers are; the running list holds every
known issue and nothing that is done; the three review sweeps have run with their obvious fixes
landed and the rest filed; rian has one handoff that tells him what to set before he deploys.

**You are done when ALL of the following are true**, and not before:
1. Every one of **N1, N2, N3, N4, N5** in `import/progress.json` is `done`, or `blocked` with a note
   saying why. (Check with `python3 -c "import json;d=json.load(open('import/progress.json'));print({t['id']:t['status'] for s in d['streams'] for t in s['tasks'] if t['id'] in ['N1','N2','N3','N4','N5']})"`.)
2. `../.venv-dev/bin/pytest tests -q` passes and `main/check.sh` prints "checks passed" on the
   current tree; `bash main/docs-check.sh --strict` prints `0 fail`.
3. Every landed change has its own commit prefixed `N:` (`git log --oneline | grep "^[0-9a-f]* N:"`).
4. `.logs/issues.md` and `.logs/decisions-for-rian.md` were regenerated by `items.py export` after
   the last `add` or `resolve`, and `python3 main/scripts/items.py sweep` lists no item you know to
   be done.
5. A handoff entry for this session is at the top of `.logs/handoff.md` (25 lines at most: what
   landed and how it was verified, what is blocked and why, what is next, what rian must do,
   including "green and ready", the environment lines to set before the deploy, and every
   after-deploy command in order: `backfill perfume` from Stream S first, then yours), and
   `/checkpoint` has been run.
6. Anything only rian or Adam can decide is on the running list with the assumption you proceeded
   under; `dfp_n`, the local server and the `main/static` symlink are gone.

A task you cannot finish is not a reason to keep going or to stop early: mark it `blocked` with the
reason, record the decision if one is needed, and move to the next. N2 runs even if N1 is blocked.
If all five are done or blocked, finish 2 to 6 and stop. If you have been going for eight hours,
mark every unfinished task `blocked` with the note "eight-hour cap; resume with /stream-n", write
the handoff, run `/checkpoint`, and stop.

## The goal line (rian pastes this after the first turn ends)
Start the session with `/stream-n`. When that first turn ends, paste this one line; the evaluator
then keeps the session working, turn after turn, until the condition is met or judged impossible:

```
/goal Stream N of the Duty Free Professor build is complete: in import/progress.json every one of the task ids N1, N2, N3, N4, N5 has status done, or blocked with a note saying why; main/check.sh has printed "checks passed" and "bash main/docs-check.sh --strict" has printed "0 fail" on the final tree; every landed change has its own commit prefixed "N:"; .logs/issues.md and .logs/decisions-for-rian.md were regenerated by main/scripts/items.py export after the last add or resolve; a handoff entry for this session is at the top of .logs/handoff.md naming the environment lines rian must set and every after-deploy command, and /checkpoint has been run; anything only rian or Adam can decide has been added to the running list with main/scripts/items.py; the dfp_n database, the local server and the main/static symlink are gone. Or stop after 150 turns.
```
