# Stream D: content (rewritten 2026-09-05 Sat, against build plan v3 §4 #8, §7 and the dated tasks on /plan)
> **Status 10 Sep:** D1, D3 and D4 landed (handoff 5 Sep); the article pages ship in 0.33.0; D2 waits on Adam's hand-ins on `/todo`. The body below is the brief as asked on 5 Sep.
**Runs:** Tue 8 (D1), Mon 14 (D3), Tue 15 (D2, D4). Adam's material arrives through `/todo`.

## Goal
The site reads as a publication and the two revenue surfaces exist: articles, airport write-ups
and category paragraphs stored as data and published across the site; email capture with an
owner-only export; sponsor positions at the sizes Stream C settles with rian.

## Tasks, in order (ids are `/plan` tasks; record each with `main/scripts/plan-set.py`)
1. **D1 Articles table + import (Tue 8).** Migration #5 (schema only, one head, rehearse on
   `dfp-devdb`): `articles(id, slug UNIQUE, title, standfirst, body_md, kind article |
   airport_writeup | category_intro, category NULL, airport_code NULL, brand_id NULL, author FK
   accounts.id NULL, status draft|published, published_at, hero_image NULL, created_at,
   updated_at)`. Public reads `published` only. Intake: `python -m app.cli articles import
   <file>` for Markdown and plain text, and for the Word files Adam hands in on `/todo`
   (`client_uploads`, stored under `uploads/<todo_id>/`); convert `.docx` to Markdown with a
   pure-Python converter pinned in `pyproject.toml` (mammoth is the obvious choice; say so in the
   handoff). Idempotent on slug; `--kind` and `--airport`/`--category` flags; drafts by default.
   Build against placeholders so Adam's files are a paste, not a build.
2. **D3 Email capture (Mon 14).** `subscribers(id, email UNIQUE, first_name, last_name,
   home_airport NULL, interests JSONB, consent_at, source, created_at, unsubscribed_at NULL)` in
   the same migration if D1 has not deployed yet, else migration #6. Fields as Adam confirms on
   `/todo` (first name, last name, email, home airport, interests). Double opt-in is not in this
   phase; consent text and timestamp are. Export: owner-only CSV via the app CLI (`app.cli
   subscribers export`), never a public route; addresses never logged. Rate-limit the POST.
3. **D2 Publish Adam's material (Tue 15).** Articles on their own pages and listed on the home
   page; airport write-ups on Stream B's airport pages; category intros on the category pages
   (B6, if it ships). RSS for articles is Stream B's B7; give it the query it needs.
4. **D4 Sponsor slots (Tue 15).** The positions and IAB sizes from C5, as a component that
   reserves its box (no layout shift), shows Adam's creative when present and nothing when not,
   and never loads a third-party script. Creative comes from `/todo` (the sponsor item) or the
   shared Drive; store under `public/` with versioned names.

## Owns
`app/models/editorial.py` (new), `app/routers/articles.py` and `subscribers.py` (new), the
articles import and subscribers export CLI commands, `web/src/pages/ArticlePage.tsx` and
`ArticlesPage.tsx` (new), the subscribe form component, the sponsor-slot component (Stream C
styles it), migrations #5/#6, their tests.

## Must not touch
Collectors, `seo.py` (ask Stream B for article JSON-LD and sitemap entries via an issue),
`styles/*`, the plan and todo pages.

## Rules
- Subscriber data is personal data: store the minimum, never log addresses, export owner-only,
  and the write route joins `tests/fixtures/mutating_routes.json`.
- `alembic heads` shows one head before you write; migrations schema-only; every data move is
  an idempotent `app.cli` command.
- Client-facing text: no em dashes. Everything else per `OVERNIGHT-RULES.md`: never deploy, own
  files only, commit prefixed `D:`, `main/check.sh` green, handoff ≤25 lines, `/plan` updated,
  decisions on the running list with `--blocks`.
