# Stream K6: reachable, quality, indexed
(written 2026-09-16 Wed, from the accepted plan W18 and W5; runs after K3 and K4, beside K7)

**Runs:** Fri 18 Sep to Sat 19, after K3's and K4's hand-backs (it hooks the slug into the
confirm path K3 owns and reads the decisions K2 built). Two sessions at once with K7.

## Goal
Every brand, product line, airport and place page exists from the moment it is discovered; every
generated page is `noindex, follow` by default; the review merges pages and a confirmed alias
fixes the final slug, old addresses redirecting flat; candidate rules produce suggestions to
index, a person approves each; the sitemap and IndexNow carry only approved pages; `hidden` stays
for a page that is wrong rather than thin. The 9 Sep floor becomes a candidate rule, not a gate.

## What already exists, so you build on it
- `seo.py` head builder takes a `noindex` flag (`noindex, nofollow` today); `robots_policy.py`
  keeps such pages crawlable; `main.py` 301s an alias brand slug and a merged variant.
- `catalog_queries._brand_counts` (K1's name for the 9 Sep floor) is the one definition of a brand
  page today, and `hidden_brand_ids` already excludes a hidden brand from it.
- K2's decisions table accepts `indexed` and `hidden` on brands, product lines and places (K2's
  brief decision 8); K2's `places` registry names each kind's address prefix; K2 ships `publish.py`
  as stubs with the signatures K6 fills (`is_indexed`, `is_hidden`, `redirect_for(slug, kind)`,
  `slug_at_confirm`).
- `indexnow.py`, `seo.sitemap_entries`, `feeds.py`.

## Tasks, in order (ids on /plan; commit prefix `K6:`)
1. **K6.1 `publish.py`.** One module every reader calls: `is_indexed(page)` (a decision `indexed`
   exists and is newest), `is_hidden(page)`, `redirect_for(slug)` (the flattened target), and the
   candidate rules: a brand meets the 9 Sep floor; a product line has two or more comparison units
   for one of its variants; an airport or place has a floor K6 proposes (fifteen published variants,
   the category-at-airport bar) and rian confirms on the running list.
2. **K6.2 Reachable on discovery.** A brand row, a product line row and a place row each have an
   address from creation (the slug rules in `urls.py`); the brand floor no longer decides existence,
   only candidacy; `hidden` is the one thing that makes a page unreachable (a 302 to the brand's
   products, never a 404, for a brand; a 302 to the brand page for a line).
3. **K6.3 The final slug at confirm, and flat redirects.** When K3's confirm records a brand or
   line alias, K6's `slug_at_confirm()` chooses the final slug from the chosen name and writes a
   redirect row through `publish.redirect_write(kind, from_slug, to_slug, decision)` into K2's
   `redirects` table (from slug, kind, to slug, since, decision id) flattened at write so A to B to C
   stores A to C.
   Renaming an indexed page is refused unless the decision says so.
4. **K6.4 `noindex, follow`, the sitemap and IndexNow.** The head reads `is_indexed`; the flag's
   text becomes `noindex, follow`; `sitemap_entries` and `indexnow` list and ping only indexed
   pages; approving an index decision pings once; de-indexing is a suggestion on the dashboard,
   never automatic when a page falls below its rule.
5. **K6.5 The indexing CLI and the dashboard list.** `app.cli index suggest` (writes the candidates
   as suggestions of kind `index`, idempotent), `app.cli index approve|remove <page>` (a decision,
   with the same batch semantics K2 gives every decision), and on the review page (K4's
   `/review`) a list of index candidates with approve per row and in bulk; the desk row shows the
   page's three facts.
6. **K6.6 Tests.** A hidden brand answers 302 everywhere; a new product line is reachable and
   noindex; a confirmed alias redirects flat through two hops; the sitemap lists only indexed
   pages; IndexNow pings only on approval; the candidate rules on fixtures; articles index on
   publish unchanged.

## Owns
`app/services/publish.py` (new), `app/services/robots_policy.py`, `app/services/indexnow.py`,
`app/services/seo.py`'s sitemap and head-flag lines only, `app/services/urls.py`'s slug rules,
the `index` CLI, `web/src/pages/ReviewPage.tsx`'s index-candidates panel only, their tests.

## Must not touch
The line page's body (K5), the confirm path itself (K3; K6 provides `slug_at_confirm()` and K3
calls it at the point K3's brief names), the schema (K2), identity.

## Rehearsal and acceptance
On a copy of staging after K3's rederive: confirm one brand alias and one line alias on the copy
through the API; the old slugs 301 to the final ones; a second alias onto the first flattens; the
sitemap on the copy lists zero pages until `index approve` is run for one brand, then one; a hidden
brand answers 302 from its page, the index, the sitemap and a product card's link. `main/check.sh`
green.

## Rollback
Redirect rows are additive; `noindex` by default means an error hides nothing that was already
public.

## Hand-back
Handoff (≤25 lines), the airport and place floor for rian to confirm on the running list, and
"K6 green and ready".
