# Changelog

The binding policy for what each kind of version bump means and what
the changelog has to carry is in [`SPEC.md` §20](./SPEC.md). The
practical recipe downstream deployments follow when reading this file
is in [`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md).

The canonical version is the `VERSION` file at the repo root;
`frontend/package.json#version` mirrors it. Deployments pin to a
specific framework version in their own `.rfc-app-version` file
(per §20.5).

While the framework is pre-1.0, minor-version bumps may introduce
breaking changes; the entry below each such bump documents the
upgrade steps a deployment must apply.

Upgrade-steps blocks use the [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
/ [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) normative-
language convention per `SPEC.md` §20.4. **MUST** / **SHALL** steps
are required; **SHOULD** steps are recommended (the framework's
default and tested path); **MAY** steps are optional. Upgrades that
skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.

## 0.55.0 — 2026-06-09

**Minor — the registry reconcile now prunes projects the registry no longer
declares; read the upgrade note.**

The registry mirror was additive-only: it upserted the projects/collections a
deployment's `projects.yaml` declares but never removed ones it had dropped.
Re-pinning a deployment to a different registry (or editing `projects.yaml` to
remove a project) therefore stranded the old project's collections and its
cached entries in the database — dead rows that, at scale, starved writes (a
deployment's PPE accumulated ~1,200 orphaned entry rows this way and had to be
reset by hand).

- **Prune-on-reconcile.** `registry.refresh_registry(..., prune=True)` now calls
  `projects.prune_absent_projects`, which deletes any project absent from the
  freshly-parsed registry together with its collections and every project-scoped
  row (entries + their satellites, threads + messages, changes, PRs, stars,
  watches, …) in one transaction, with FK enforcement off for the multi-table
  delete and a `PRAGMA foreign_key_check` backstop that rolls the whole thing
  back rather than leave a dangling reference.
- **Whole-project granularity, and only the default-safe triggers.** A project
  still present in the registry is never touched here (pruning individual entries
  within a present project belongs to the best-effort content cache). The
  deployment's **default project is never pruned**, whatever the registry says.
  Pruning runs only on the two full-reconcile triggers — **startup** and the
  **periodic reconciler sweep** — never on incidental refreshes (a collection
  create, a registry webhook), so an in-app refresh can't wipe a project.
- **Safe against a transient registry read.** The prune is reached only after a
  successful parse of a non-empty registry: `parse_registry` rejects an empty
  project list and the registry read raises on a missing file / transport error
  (the reconciler keeps last-good on that error). A flaky read can't trigger a
  wipe.

**Upgrade note (no steps required for a correctly-configured deployment).** On
the first startup after upgrading, any project that exists in the database but is
**absent from your `projects.yaml`** will be pruned. For a deployment whose
database was only ever populated from its current registry this is a no-op (every
DB project is in `projects.yaml`). If you have deliberately out-of-band projects
in the database (there is no supported way to create one — every project goes
through `projects.yaml`), add them to the registry before upgrading. No database
migration and no configuration change.

## 0.54.1 — 2026-06-09

**Patch — the Gitea-OAuth sign-in no longer 500s when an account was first
created via OTC; no operator action required.**

`auth.provision_user` (the Gitea-OAuth callback path) matched an existing
`users` row only by `gitea_id`. A human who signed in first via the OTC
email path owns an email-only row (`gitea_id` NULL); when they later signed
in via Gitea OAuth with the same email, the lookup missed and the code
INSERTed a new row — colliding on the `idx_users_email` case-insensitive
unique index and raising `IntegrityError`, which 500-ed the callback. Now,
when no `gitea_id` row exists, `provision_user` reconciles by email and links
the OAuth identity (`gitea_id` / `gitea_login` / profile) onto the existing
row — the mirror of the OTC linker, which already links an OAuth-era email row
on first OTC sign-in. The §6.1 owner-zero bootstrap is applied on link
(matching the fresh-insert path); the row's `permission_state` is preserved so
linking never changes a human's admission status as a side effect.

## 0.54.0 — 2026-06-09

**Minor — the AI "Ask" affordance now works from an entry's canonical view
(§8.12); no operator action required.**

AI chat is an editing activity (a turn can emit proposed edits) and only runs
on an edit branch. On an entry's canonical `main` view the right column is the
human-discussion panel, not the chat panel — so invoking "Ask" (the selection
tooltip or the prompt bar) from the reading view pushed a chat turn that had
nowhere to render: it silently did nothing. Surfaced by dogfooding a non-default
project's spec entry on a live deployment.

- **Asking while reading "just works" (Option B).** When Ask is invoked from
  the canonical view, the app transparently cuts an edit branch via the same
  dispatch as Start Contributing (`start-edit-branch` for a super-draft,
  `promote-to-branch` for an active RFC — idempotent, so it reuses an existing
  edit branch rather than double-cutting), navigates onto that branch so the
  chat panel mounts, and runs the question (text + the selected quote) there
  once the branch's chat thread has resolved. The viewer lands in the chat with
  the streaming answer, quote intact.
- **Graceful degradation.** A signed-out viewer keeps the read-only path
  (sign-in prompt; Ask disabled) and a viewer who cannot contribute has the
  branch cut rejected server-side and the error surfaced — no silent no-op and
  no spurious branch. Asking from a branch already, and Flag on the canonical
  view (which still opens a human discussion thread), are unchanged.

Frontend-only: the branch chat endpoints already worked on a branch. No
database migration and no configuration change.

## 0.53.0 — 2026-06-09

**Minor — the branch/edit/body subsystem is now three-tier (project /
collection) aware (gap G-15); no operator action required.**

§22 migrated the READ/catalog path to the three-tier model, but the
WRITE/branch/body subsystem still hardcoded the default project's default
collection: it resolved every meta-resident entry to the default project's
content repo at `rfcs/<slug>.md`, ignoring the entry's project (its own
content repo) and its collection (a `<subfolder>/rfcs/` prefix). As a result
an entry outside the default project's default collection rendered a **blank
canonical body** (the git-backed `GET …/branches/main` read the wrong file)
and every edit / PR / body-write path hit the wrong repo. Surfaced by
dogfooding a separate project on a live deployment.

- **Single resolver.** New `projects.content_repo_for_collection(cid)` and
  `projects.entry_location(config, cid, slug) → (org, repo, md_path)` resolve
  an entry's git location from its collection (collection → project →
  content_repo, plus the collection subfolder), falling back to the default
  project's repo for a legacy / unknown collection. Every write/read path now
  shares this resolver instead of the hardcoded default.
- **Every entry write/read path is collection-resolved:** the branch-body GET
  and all branch write paths (`api_branches`: start-edit-branch, accept /
  decline / reask, manual-flush, promote-to-branch, metadata, branch chat),
  the PR family (`api_prs`: pr-draft / open-pr / merge / withdraw / review /
  resolution-branch), graduation (`api_graduation`: graduate / claim / retire
  / unretire and the module-level orchestrator + state-flip helpers),
  mark-reviewed, the idea-PR merge / decline / withdraw + proposal preview,
  and metadata edit/bulk/migrate (`api_metadata`). `bot.open_metadata_pr` and
  `bot.mark_entry_reviewed` gained a `file_path` parameter (was hardcoded to
  the repo root). The branch cache (`refresh_meta_branches`), the webhook
  corpus-refresh dispatch, and the hygiene branch-delete now span **every**
  project's content repo, not just the default.
- **Collection-scoped body-read routes** (new, additive):
  `GET /api/projects/{pid}/collections/{cid}/rfcs/{slug}/main` and
  `…/rfcs/{slug}/branches/{branch}` disambiguate a slug that exists in two
  collections (G-5) and read the entry's own repo. The slug-only routes are
  kept and are now collection-aware via the entry's cached row, so existing
  clients are unaffected.
- **Frontend.** `getRFCMain` / `getBranch` take optional `projectId` +
  `collectionId` and call the scoped routes when present (RFCView threads its
  `pid`/`cid`, mirroring the v0.52.1 entry-detail fix); both fall back to the
  slug-only routes otherwise.

No database migration and no configuration change. Existing default-collection
entries are unaffected (the resolver returns the default repo and a repo-root
path for them).

_Known boundary:_ the branch / thread / change / PR cache tables remain
slug-keyed, so a slug that genuinely exists in two collections is still
ambiguous on the slug-only *write* routes; the collection-scoped body-read
routes resolve it for the canonical view, and the resolver makes every write
target the correct repo via the entry's own collection. Full slug
de-duplication across collections on the write family is tracked separately.

## 0.52.3 — 2026-06-09

**Patch — unmatched routes render a 404 instead of a blank page or a
silent redirect home (no operator action required).**

- **The top-level catch-all redirected any unknown path to `/`**, and the
  nested `/admin/*`, `/p/:projectId/*`, and `/docs/*` route groups had no
  catch-all (so an invalid subpath rendered an empty pane). Added a shared
  `NotFound` 404 page and wired it into all four route groups, so a bad or
  typo'd URL — including a stale `/admin/users/allowlist/graduation`-style
  path — now shows a clear "page not found" with a link back to the catalog.
  (Client-side 404 UI; the SPA still serves over HTTP 200, as before.)

## 0.52.2 — 2026-06-09

**Patch — admin sidebar nav links no longer accumulate URL segments (no
operator action required).**

- **The `/admin` left-rail links were relative**, so under the `/admin/*`
  nested route each click resolved against the current URL and *appended* a
  segment — e.g. clicking Users → Allowlist → Graduation produced
  `/admin/users/allowlist/graduation` instead of `/admin/graduation`. Fixed:
  the rail `NavLink`s now use absolute targets (`/admin/<tab>`) with `end`
  for exact active-state matching.

## 0.52.1 — 2026-06-08

**Patch — collection-scoped entry-detail fetch fix (no operator action
required).**

Caught by the §9 deployed-environment E2E harness (0.52.0) running against
a PPE host whose content is cleanly isolated per collection:

- **Entry detail in a named collection 404'd ("Error: Not found") and its
  metadata panel never rendered.** `RFCView` computed the collection id
  from the route but called `getRFC(pid, slug)` without it, so an entry was
  always fetched via the project's *default*-collection route
  (`/api/projects/<pid>/rfcs/<slug>`). For an entry that lives only in a
  named collection that route 404s. The bug was latent since the
  multi-collection work — local/Tier-1 stacks masked it because the same
  slug was also reachable through the default collection; a deployment with
  per-collection-isolated content surfaces it. Fixed: all three `getRFC`
  call sites in `RFCView` now pass the collection id (and the load effect
  re-runs on collection change).

Test-only (not in the deployed artifact): the deployed-env E2E harness now
pre-records cookie consent via `addInitScript` so the bottom-fixed consent
banner can't intercept catalog row-select clicks on the slower deployed
edge, and `testing/seed-ppe.sh` fails loudly on any non-2xx Gitea response
(a swallowed 403 had let a missing-repo seed reach the deploy as a 502).

## 0.52.0 — 2026-06-07

**Minor — deployed-environment E2E harness (new opt-in test-auth surface;
no operator action required for existing deployments).**

The §9 pipeline's middle stage — running the Playwright E2E suite against
a *deployed* pre-prod host — was unreachable because the suite was bound
to local-only scaffolding (a docker-seeded faceted collection, a
SQLite-injected owner, and Mailpit for the OTC code). This release makes
the same suite run green against a deployed host.

- **New gated test-auth endpoint, `POST /auth/test/login`.** Mints an
  authenticated **owner** session for a single pre-configured identity, to
  let the E2E suite sign in without a mail sink. It is **fail-closed**:
  returns `404` unless **both** `E2E_TEST_AUTH_SECRET` and
  `E2E_TEST_AUTH_EMAIL` are set (so it is inert in production, which sets
  neither); requires the secret in the `X-Test-Auth-Secret` header
  (constant-time compare; wrong/absent → `404`); only mints the one
  configured email (any other → `403`); and logs a `WARNING` at startup
  when enabled. **Leave both env vars unset on production.**
- **E2E harness parameterized for deployed runs.** `e2e/lib/auth.js`
  branches on `E2E_TEST_AUTH_SECRET` (deployed test-login vs. the local
  Mailpit OTC path); `OWNER_EMAIL` reads `E2E_OWNER_EMAIL`. `BASE_URL` was
  already honored. The local Tier-1 path is unchanged.
- **`testing/seed-ppe.sh`** seeds a dedicated, prod-untouching PPE
  registry + content repo (faceted `bdd` collection) so the deployed
  fixtures never leak onto real content.

Design note: `docs/design/2026-06-07-deployed-env-e2e-harness.md`.

## 0.51.1 — 2026-06-07

**Patch — two §22 correctness fixes (no operator action required).**

- **Faceted catalog / bulk bar now scope to the named collection in the URL.**
  `useCollectionId` read the route param, but the catalog renders above the
  `c/:collectionId` route, so it always fell back to the default collection —
  the faceted filter (§22.4a SLICE-3) and bulk action bar (SLICE-5) never
  scoped to a named, `fields:`-bearing collection in the deployed app. It now
  resolves the `/c/<id>/` segment from the path. Caught by a new end-to-end
  Playwright suite exercising the metadata UI against a real browser + Gitea.

- **Heal the migration-029 vs registry-mirror collection-id divergence.** On a
  deployment that already held ≥2 projects when migration 029 ran, the default
  project's collection was seeded as the project id while the mirror expects
  `'default'`, so the mirror inserted a duplicate empty collection. A new
  startup reconciler (`projects.reconcile_default_collection_id`, the
  collection-grain twin of the §22.13 re-stamp) renames the divergent
  collection to `'default'` before the mirror runs. Idempotent; a no-op on
  fresh / single-project / already-aligned deployments (incl. the default
  `document` deployment).

Also: the per-IP auth rate-limiter budgets are now env-overridable
(`RATELIMIT_OTC_REQUEST_MAX` / `_VERIFY_MAX` / `_CHECK_MAX`) for test/PPE stacks
that drive auth from one IP; production keeps the secure defaults. The Tier-1
test harness is brought current with the §22 three-tier app.

## 0.51.0 — 2026-06-07

**Minor — bulk tag/untag metadata edit (§22.4a SLICE-5).**
An authorized user can now apply one metadata field change to many catalog
entries at once. A new endpoint
`POST /api/projects/{id}/collections/{cid}/meta/bulk` takes
`{slugs, op: set|add|remove, field, value}`, validates each entry against the
collection's `fields:` schema at the write boundary (INV-4), writes the passing
entries' `<slug>.meta.yaml` **sidecars** in a **single commit** (D7: bulk = one
commit, reusing the SLICE-4 sidecar write-through), re-ingests, and returns
`{applied, rejected}` so partial failures (missing entry, invalid value) are
reported without sinking the batch. `set` works for any field; `add`/`remove`
operate on a `tags`-type field. In the faceted catalog, contributors now get a
per-row selection checkbox and a sticky bulk action bar (one "Set ▾" control per
enum field, add/remove-tag for tags fields); a successful apply toasts the
applied/skipped counts and refreshes the list. This completes PUC-2 of the
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
design (§5.3, §6.4/§6.5).

No deployment action required. The endpoint and the bulk bar are additive and
opt-in per collection: a collection with no `fields:` block (INV-5 — the
default `document` collection) shows no selection UI and the endpoint returns
`422`, so an N=1 deployment sees zero change.

## 0.50.0 — 2026-06-07

**Minor — single-entry metadata edit + sidecar-aware writes (§22.4a SLICE-4).**
An authorized user can now edit one entry's schema-defined metadata directly from
its detail view. A new endpoint
`POST /api/projects/{id}/collections/{cid}/rfcs/{slug}/meta` takes `{values:{…}}`,
validates them against the collection's `fields:` schema at the write boundary
(INV-4), writes them to the entry's `<slug>.meta.yaml` **sidecar** with a **direct
commit** (D7 — no PR for authorized roles), and re-ingests. A legacy entry is
**lazy-migrated** to a clean body-only `.md` + sidecar on its first metadata edit.
The detail view renders one control per declared field (enum→select, tags→chips,
text→input), read-only without contribute access; `GET` on an entry now returns
its `meta` mapping and a `can_edit_meta` capability. This completes PUC-1 of the
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
design (§7.2); bulk edit (SLICE-5) follows.

Carried from SLICE-1: **every entry write path is now sidecar-aware.** Graduation,
ownership claim, retire/un-retire, mark-reviewed, the body-edit accept/flush
wrappers, and the PR-replay wrappers all dual-read an entry (so a migrated
body-only `.md` no longer crashes `entry.parse`) and write metadata changes to the
sidecar, keeping the `.md` body pure (INV-2) and never re-growing frontmatter; a
legacy entry lazy-migrates on its first metadata-bearing write. With the write
paths safe, the **Owner-gated collection-migration endpoint**
`POST /api/projects/{id}/collections/{cid}/migrate` now ships (PUC-5): it converts
a collection's legacy-frontmatter entries to clean body-only `.md` + sidecars in
one commit, idempotently.

Non-breaking and opt-in (INV-5): a collection with **no `fields:`** exposes no
edit panel and the edit endpoint returns 422 ("no editable fields"); the §22.13
default `document` collection declares none — so **N=1 deployments see no change**.

**Upgrade steps**

- No schema migration. The edit and migrate endpoints are additive; the sidecar
  storage layer (mig 033) and `meta_json` index (mig 034) shipped in 0.47.0/0.49.0.
- A deployment adopts single-entry editing by declaring an `enum`/`tags`/`text`
  `fields:` block in a collection's `.collection.yaml` (SLICE-2). Contributors+
  on that collection (§22 Part B / S3 scope roles) may then edit from the detail
  view; the change is a direct commit to the entry's sidecar.
- Operators MAY run `POST …/collections/{cid}/migrate` (collection Owner only) to
  convert a collection's existing entries to clean body-only docs + sidecars up
  front. It is idempotent and safe to re-run; dual-read means an un-migrated
  collection keeps working, and entries lazy-migrate on their first metadata edit
  regardless. **No data migration is required.**

## 0.49.0 — 2026-06-07

**Minor — faceted left-pane filtering (§22.4a SLICE-3).** A collection that
declares a metadata field schema (`fields:`, SLICE-2) now gets a **faceted
catalog left pane**: one collapsible filter group per `enum`/`tags` field plus
state, each with per-value **result counts** and multi-select checkboxes, and a
"filter values…" search box on `tags` groups so they stay usable at many values.
Filters compose — OR within a field, AND across fields — and a "malformed
metadata only" toggle surfaces entries failing their schema (INV-3). This is
SLICE-3 of the
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
design (§7.2); the single-entry and bulk metadata-edit UIs (SLICE-4/5) follow.

The collection-scoped list endpoint
(`GET /api/projects/{id}/collections/{cid}/rfcs`, and the project-scoped
default-collection alias) now honours filter query params
(`?priority=P0&tags=checkout&state=active&malformed=true`), returns a
`facets: {field → {value → count}}` block with drill-down counts, and includes
each entry's metadata `meta` mapping; an unknown filter field is a 400. Migration
034 adds the additive `cached_rfcs.meta_json` column that persists per-entry
metadata values for the index.

Non-breaking and opt-in (INV-5): a collection with **no `fields:`** keeps the
existing state-chip catalog unchanged, and the §22.13 default `document`
collection declares none — so **N=1 deployments see no change**. The unscoped
cross-collection `GET /api/rfcs` is untouched.

**Upgrade steps**

- Migration 034 (`cached_rfcs.meta_json`) applies automatically on startup
  (additive, nullable). It is populated lazily as the corpus reconciler /
  content-repo webhooks re-ingest each collection; until an entry is re-ingested
  its `meta_json` is NULL and it contributes no facet values. Operators wanting
  facets populated immediately MAY trigger a corpus refresh (the reconciler sweep
  on next startup does this). **No data migration is required.**
- A deployment opts a collection into faceting by declaring an `enum`/`tags`
  `fields:` block in its `.collection.yaml` (SLICE-2). A collection with no
  `fields:` keeps the existing state-chip catalog unchanged.

## 0.48.0 — 2026-06-07

**Minor — collection field schema + central validation (§22.4a SLICE-2).** A
collection can now declare a small **field schema** in its `.collection.yaml`
(`fields:` block), so its entries carry structured, typed metadata — `priority`,
`tags`, and any custom fields the deployment defines. The schema is mirrored into
the collection record and served on the collection API; a new central validator
checks each entry's stored values against it. This is SLICE-2 of the
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
design (§7.2), building on the SLICE-1 sidecars; faceted filtering (SLICE-3) and
the edit UIs (SLICE-4/5) follow.

Non-breaking and opt-in: a collection with **no `fields:`** behaves exactly as
before (INV-5), and the §22.13 default `document` collection declares none
(**N=1 sees no change**). No DB migration — the normalized schema rides in the
existing `collections.config_json` column.

Added:

- **`app/metadata_schema.py`** — the one place that knows a collection's field
  shapes. `parse_fields(raw)` normalizes a `.collection.yaml` `fields:` block
  **leniently** (INV-3): a bad block or a bad field def (non-mapping, unknown
  type, `enum` without a non-empty `values:` list) is skipped with a warning,
  never fatal — a typo in one field can't drop the whole collection from the
  mirror. `validate(values, fields)` returns advisory `Problem`s (empty =
  clean). v1 field types: **`enum`** (single value, controlled by a required
  `values:`), **`tags`** (a list; free-form unless `values:` given), **`text`**
  (a free string). `ref` / `multi-enum` are deferred (design §2). Keys an entry
  carries that the schema does not declare ride along untouched, never flagged
  (INV-7).
- **Registry ingest** (`registry.parse_collection_manifest`) reads the `fields:`
  block into the collection config (→ `config_json`), beside `enabled_models`.
- **Collection read + API** (`collections.get_collection`) unpacks the schema
  and `GET /api/projects/{id}/collections/{cid}` serves it as `fields` (`null`
  when unset).
- **Advisory validation at ingest** — the corpus mirror
  (`cache._refresh_collection_corpus`) validates each entry against its
  collection's schema and flags a violation as `metadata_malformed` **without
  blocking the read** (INV-3), OR-ed onto the SLICE-1 sidecar-syntax check.
  Write-boundary **enforcement** (a 422 on the metadata-edit endpoints) lands
  with those endpoints in SLICE-4/5.

### Upgrade steps (0.47.0 → 0.48.0)

- **No migration, no operator action.** A deployment opts in per collection by
  adding a `fields:` block to that collection's `.collection.yaml`; until it
  does, behavior is byte-for-byte unchanged.
- A collection's field schema is edited in git for v1 (in-app schema management
  is deferred, design D8). After editing `.collection.yaml`, the registry mirror
  picks the schema up on its next webhook / reconciler sweep.
- No config change. No content change is required.

## 0.47.0 — 2026-06-07

**Minor — metadata sidecars: storage + dual-read + migration tool (§22.4a
SLICE-1).** Entry metadata can now live in a per-entry `<slug>.meta.yaml`
**sidecar**, with the `.md` kept as pure prose (INV-2). The corpus mirror reads
the sidecar when present and falls back to legacy top-of-document frontmatter
otherwise (**dual-read**, INV-6), so existing corpora load byte-identically.
This is SLICE-1 of the
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
design (§7.2); the collection `fields:` schema + validation (SLICE-2), faceted
filtering (SLICE-3), and the edit UIs (SLICE-4/5) follow.

Non-breaking and opt-in: a collection with no sidecars behaves exactly as
before, and the §22.13 default collection is untouched (**N=1 sees no change**).

Added:

- **Dual-read parser** (`app/metadata.py`) — `read_entry(md, sidecar)` yields
  identical records whether metadata comes from a sidecar or legacy
  frontmatter (INV-6); a malformed sidecar never hard-fails a read — the entry
  still loads and is flagged (INV-3). Unknown / forward-compat keys ride along
  untouched through parse→serialize and the migration (INV-7;
  `Entry.extra`).
- **Frontmatter→sidecar migration tool** (`metadata.migrate_collection`) — a
  deterministic, idempotent tool that lifts a collection's legacy entries into
  sidecars + body-only `.md`s in **one commit** (new Gitea `change_files`
  batch); a fully-migrated collection is a no-op. The **operator trigger** for
  it (an Owner-gated endpoint) is intentionally **deferred to SLICE-4**: the
  propose/graduate/mark-reviewed/edit write paths still read `.md` frontmatter
  directly, so they must become sidecar-aware before a corpus is migrated in
  production. Until then the tool is shippable groundwork, not yet wired to a
  production trigger (INV-8: the engine write paths are unchanged this slice).
- **Malformed-metadata flag** — migration `033_metadata_malformed.sql` adds
  `cached_rfcs.metadata_malformed` (additive); the corpus mirror derives it and
  the catalog + entry-detail APIs surface `metadata_malformed`. A degenerate
  sidecar (malformed / empty / slug-less) never drops the entry — it loads with
  its slug backstopped from the filename and is flagged (INV-3).
- **INV-7 at graduation** — graduation now carries an entry's unknown /
  forward-compat frontmatter keys through the rebuild instead of dropping them.

### Upgrade steps (0.46.2 → 0.47.0)

- The framework **MUST** apply migration `033_metadata_malformed.sql` — it runs
  automatically at startup (additive column, no rebuild, default `0`).
- **No operator action** otherwise. With no sidecars present (the default after
  this upgrade) every corpus stays on the legacy frontmatter path, byte-for-byte
  as before. The frontmatter→sidecar migration is **not** operator-triggerable
  yet (its endpoint lands in SLICE-4); dual-read makes the storage change
  invisible until then.
- No config change. No content change is required.

## 0.46.2 — 2026-06-07

**Patch — `SPEC.md` §22.4a contract amendment: entry metadata is
collection-configured, not type-driven (doc-only).** Reframes the binding
§22.4a "collection type" contract so a collection's entry **metadata schema**
is **collection-configured** — a `fields:` schema declared in
`.collection.yaml` plus per-entry `<slug>.meta.yaml` **sidecars** — rather than
a frontmatter schema hard-wired to the collection's `type`. Item 3's
**type-specific surfaces** (release planning for `specification`;
scenario/coverage views for `bdd`) are **deferred** to a future design; the
`bdd` coverage capability is recorded there as a future `ref`-field surface
rendered as hyperlinks (never fusing corpora across collections). What `type`
still selects is the entry-noun terminology (item 2, shipped v0.45.0) and the
default `initial_state` / review posture (§22.4b–c).

This is **SLICE-0** of the
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
design (§7.2) — the contract amendment that unblocks the build slices. It
supersedes the [per-type-surfaces](./docs/design/2026-06-06-per-type-surfaces.md)
draft (D11; banner already on that doc).

**No operator action; no schema or behavior change.** Per-type frontmatter
validation was never implemented — the engine treats every entry as markdown +
frontmatter regardless of type — so amending the contract changes no runtime
behavior, no migration, and no deployment config. Sidecar storage, the
collection `fields:` schema, validation, and the metadata UI arrive in later
slices (SLICE-1+).

Changed:

- **`SPEC.md` §22.4a** — reframed: `type` selects terminology + default
  `initial_state`/review posture; entry metadata is collection-configured
  (`fields:` + `<slug>.meta.yaml` sidecars); type surfaces deferred. The
  `document`/`specification`/`bdd` bullets updated accordingly; a metadata
  amendment blockquote records the supersession.
- **`SPEC.md` §2 and §22 forward-pointers** — the two amendment blockquotes
  that said "entry frontmatter schema is type-dependent" now read
  "collection-configured, not type-driven (§22.4a, as amended)".

## 0.46.1 — 2026-06-06

**Patch — migration 029 hardening for the §22.13 re-stamp aftermath.** Fixes a
crash deploying the three-tier series (v0.40.0+) onto a deployment that went
through the v0.39.0 `default`→`<id>` project re-stamp: the re-stamp updated
`cached_rfcs.project_id` but **not** the entry-satellite tables, leaving rows at
the stale `project_id` that migration 029's per-project collection backfill could
not map (`NOT NULL constraint failed: cached_branches__new.collection_id`), plus
stale rows that duplicate freshly-re-mirrored ones (`UNIQUE` collision) and stale
rows whose entry no longer exists. No operator action; **no schema change** — 029
gains a repair prologue only.

Fixed:

- **Migration 029 repair prologue** — before rekeying, each entry-satellite table
  (`cached_branches`, `branch_visibility`, `stars`, `watches`, `pr_seen`, …) has
  its `project_id` re-derived from its entry (`cached_rfcs`, by slug); rows whose
  entry no longer exists are dropped (stale cache, rebuildable from gitea), and
  stale rows that duplicate an already-correctly-stamped row are dropped (keeping
  the fresh copy). A no-op on a clean/fresh deployment (empty or already-
  consistent satellites), so fresh installs are unaffected — the existing 029
  test suite passes unchanged, plus a new regression test for the stale/dup/
  orphan shape.

No upgrade steps: applying 029 (now repaired) is automatic on deploy; the repair
only mutates the rebuildable `cached_*` caches.

## 0.46.0 — 2026-06-06

**Minor (non-breaking) — §22 three-tier refactor, slice S6 (remainder):
*request-to-join + the cross-collection inbox (§22.8).* S4 shipped the invite
half of joining a gated scope (an Owner grants a role directly); this ships the
other half — a user who knows a scope exists asks to join it, naming a desired
role, and the request is fanned out to that scope's Owners across the subtree
(the cross-collection inbox, §22.11), who accept (writing the `memberships` row)
or decline. Purely additive: one new table (no rebuild), one new endpoint group,
and inbox/affordance UI; no change to existing read or write semantics.**

See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
(Part E slice S6) and `SPEC.md` §22.8 / §22.11. This closes the **request-to-join**
item flagged open at 0.45.0; the per-type *surfaces* (§22.4a items 1 & 3) remain
the last S6 item, still pending a discovery/spec pass.

Added:

- **Request-to-join a scope (§22.8)** — `join_requests` (migration 032), a
  scope-grain analogue of `contribution_requests`: a `(scope_type ∈
  {project,collection}, scope_id, requester, requested_role, message, status)`
  row, one-open-per-`(scope, requester)`. New endpoints under
  `/api/scopes/{scope_type}/{scope_id}/`: `GET join-target`, `POST
  join-requests`, and the Owner's `POST .../{id}/accept` / `.../{id}/decline`.
  Accept writes the membership via `memberships.grant` (the §22.8 "accepting
  writes the membership row"); the request POST does **not** require the scope be
  readable — that is how one joins a *gated* scope they were told about.
- **The cross-collection inbox (§22.11)** — a join request fans one actionable
  §15 notification to every Owner whose reach covers the scope (collection
  Owners + project Owners + global Owners + deployment owners/admins), so it
  surfaces in the one deployment-wide inbox of anyone who can grant it. New
  event kinds `join_request_on_scope` (owner-facing, Accept/Decline inline) and
  `join_request_accepted` / `join_request_declined` (requester-facing).
- **Frontend** — a "Request to join" affordance in the project collection
  directory and the per-collection catalog footer (shown when the viewer is
  signed in, granted, and holds no role reaching the scope — the new
  `viewer.can_request_join` flag), a `JoinRequestModal`, and the actionable
  `JoinRequestRow` in the inbox.
- **`auth.effective_role_at_scope(user, scope_type, scope_id)`** — the
  scope-grain twin of `effective_scope_role` (which keys on a collection),
  folding global → project for a project target; drives the "already a member?"
  gate and the `can_request_join` flag.

No upgrade steps: migration 032 is additive (a new table; no rebuild, no FK
changes to existing tables), and no env var or config changes are required.

## 0.45.0 — 2026-06-06

**Minor (non-breaking) — §22 three-tier refactor, slice S6: *the SPEC merge +
per-collection model universe + the type-driven entry noun.* The three-tier
model (deployment → project → RFC collection) is now written into the binding
`SPEC.md` as a canonical §22, so the spec finally reflects the shipped S1–S5
behavior; a collection may narrow its project's model universe; and the entry
noun ("RFC" / "Spec" / "Feature") follows the collection's type. Purely additive
over S5 — one additive column (no rebuild), docs, and two small features; no
change to existing read or write semantics.**

See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
(Part E slice S6; Parts A/B/D, merged into `SPEC.md` §22). With S6 the §22
three-tier model is realized in the binding contract. **Two S6 items remain
open and are carried to a follow-up slice** (they want a discovery/spec pass
first, lacking BDD scenarios in Part C): the per-type *surfaces* — per-type
frontmatter schemas, the `specification` release-planning data model, the `bdd`
scenario/coverage views (§22.4a items 1 & 3, flagged "first proposals" in the
design doc) — and **request-to-join + the cross-collection inbox** (§22.8; S4
shipped the invite half).

Added:

- **§22 merged into `SPEC.md` (the keystone)** — a canonical §22.1–§22.14 writes
  the three-tier model into the binding spec: the tiers + collection-grain
  isolation, the registry (`projects.yaml`) + `.collection.yaml` manifests,
  one-content-repo-per-project, per-collection slug identity, collection
  `type`/`initial_state`/`unreviewed`, two-tier (narrow-only) visibility, the
  unified `{owner, contributor}` role vocabulary at `{global, project,
  collection}`, the four-layer most-permissive union, discovery/joining,
  runtime branding, `/p/<project>/c/<collection>/` routing, the one inbox, the
  per-collection model universe, and the default-project+collection migration.
  The **S3 keystone reinterpretation of §B.1/§B.3** lands here: a plain granted
  account is a granted *account*, not a global write role; "global RFC
  Contributor" is an explicit `scope_type='global'` grant; the implicit-public
  write baseline is grandfathered onto the migration-seeded default collection
  only. Forward-pointer amendment notes added at §1/§2/§5/§6. The registry +
  manifest formats are documented for operators in
  [`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md).
- **Per-collection model universe (§22.12)** — a collection's `.collection.yaml`
  may carry an `enabled_models` list that narrows its project's universe, which
  in turn narrows the deployment `ENABLED_MODELS`. Resolution (extending
  §6.6/§6.7) is `funder ∩ per-entry models ∩ collection ∩ project`, with the
  operator providers as the ceiling — a collection can only narrow, never widen.
  An absent list inherits the parent; an empty list opts the collection out of
  AI. Surfaced as `enabled_models` on `GET /api/projects/:id/collections/:cid`.
- **Type-driven entry noun (§22.4a)** — the displayed noun for an entry follows
  the collection type (`document` → "RFC", `specification` → "Spec", `bdd` →
  "Feature"), defined once in the framework and read from the API
  (`entry_noun` on the collection, directory, and project surfaces) rather than
  hardcoded. The catalog's propose control and the propose modal name entries
  accordingly.

Schema:

- **Migration `031_collection_enabled_models.sql`** — additive
  `collections.config_json` (paralleling `projects.config_json`), holding the
  per-collection `enabled_models`. No table rebuild; `NULL` means "inherit the
  project's universe."

Upgrade steps (from 0.44.0):

1. Rebuild and redeploy as usual; the framework **SHALL** apply migration
   `031_collection_enabled_models.sql` automatically on start (additive column,
   no data movement, no operator action).
2. A deployment **MAY** now add an `enabled_models` list to any
   `.collection.yaml` to narrow that collection's model universe, and **MAY**
   create `specification`- or `bdd`-typed collections to get the corresponding
   entry noun. Both are optional; absent them every collection behaves exactly
   as before.

## 0.44.0 — 2026-06-06

**Minor (non-breaking) — §22 three-tier refactor, slice S5: *in-app
create-project + the global directory.* A global Owner can now stand up a new
project end-to-end from the UI — the bot provisions a Gitea content repo and
commits the project to `projects.yaml`, and the registry mirror picks it up — and
the deployment directory shows a role-aware empty state. Purely additive over S4:
one new endpoint and UI, no schema migration, and no change to existing read or
write semantics. Completes acceptance scenarios `@S5` (C3.1–C3.2: the
global-directory empty states).**

See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
(Part E slice S5, Part C.3 scenarios C3.1–C3.2). With S5 the role-and-empty-state
focus of Parts B/C is complete; type modules, membership lifecycle, hardening,
and the SPEC merge remain S6.

Added:

- **In-app create-project (§22 S5 / §A.2)** — `POST /api/projects` (global-Owner
  only) provisions a Gitea content repo under the deployment org (seeding a
  `README.md` so `main` exists), commits a new project entry to the registry's
  `projects.yaml`, then re-runs the registry mirror so the `projects` + default
  `collections` rows flow from the registry (§22.2 keeps the registry the source
  of truth). The bot remains the only Git writer (§1); the action is logged
  (`create_project`) for the §6.5 trail. The body takes `project_id` (a slug, not
  `default`), `name`, `type` (the initial/default collection's), optional
  `visibility` (defaults to `public`), and an optional `content_repo` name
  (defaults to `<id>-content`).
- **Create-project gate** — `auth.can_create_project`: "+ New project" is a
  global-Owner action — a deployment owner/admin, or a holder of an explicit
  `scope_type='global'` Owner grant. A project- or collection-scope grant, or a
  global RFC Contributor, cannot create projects (that role creates collections,
  not projects).
- **Deployment-directory capability + redirect signals** — `GET /api/deployment`
  now carries a `viewer` block (`can_create_project`) and
  `default_project_readable` (whether the N=1 land-in-corpus redirect target is
  reachable by this viewer). The frontend bounces into the default project only
  when it is readable; a gated default (C3.2) or an absent default (C3.1, a
  deployment with no projects) falls through to the directory's empty state
  instead of a 404.
- **Role-aware global directory (§22 C3.1–C3.2)** — a global Owner landing on an
  empty deployment directory sees a "Create your first project" CTA (opening the
  create-project modal: id, name, type, visibility, optional content-repo); a
  non-owner sees "Nothing has been shared with you yet" and no create action. The
  directory also gains an Owner-only "New project" control when it is non-empty.

Upgrade steps:

1. No operator action is required. S5 adds one endpoint and UI only — there is no
   migration and no change to existing authorization outcomes. A deployment that
   was declaring projects directly in `projects.yaml` keeps working unchanged;
   the new UI is an additional, equivalent way to commit the same registry entry.

## 0.43.0 — 2026-06-06

**Minor (non-breaking) — §22 three-tier refactor, slice S4: *invitation
surfaces + role-aware empty states.* An Owner can now grant scope roles from the
UI, and each tier shows a role-appropriate empty state. Purely additive over S3:
new endpoints and UI, no schema migration, and no change to existing read or
write semantics. Completes acceptance scenarios `@S4` (C2.1–C2.7: invitation
reach, bounding, supersession, and the pending-account floor; C3.3–C3.5: the
create-first-collection and propose-first empty states).**

See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
(Part E slice S4, Part C.2 and C.3). The in-app create-project flow and the
global-directory empty states (C3.1–C3.2) remain S5.

Added:

- **Scope-role invitation surface (§22 C.2)** — `POST
  /api/projects/<id>/members` grants `{owner, contributor}` to an existing
  account (looked up by email) at the project, or at one collection (with
  `collection_id`). The grant writes a `memberships` row immediately and emits a
  §15 personal-direct notification (`scope_role_granted`) naming the project and
  role — a direct grant, not an accept round-trip. `GET` lists the project
  subtree's grants (project-Owner view); `DELETE
  /api/projects/<id>/members/<user_id>` (optionally `?collection_id=`) revokes.
- **Invitation gates** — `auth.can_invite_at_project` /
  `can_invite_at_collection`: managing membership is an Owner capability bounded
  by the inviter's reach. A project Owner grants at the project or any collection
  within it; a collection Owner grants only at that collection (never the project
  or globally); an RFC Contributor manages no membership.
- **Broader-scope-supersedes (§22 C.2.6)** — granting at a broader scope removes
  the grantee's narrower rows that the new grant subsumes (same-or-lower role
  rank within the subtree); a stronger child grant survives a weaker parent grant
  (no negative override).
- **Pending-account floor (§22 C.2.7)** — a grant to a `pending` deployment
  account is recorded but confers no write until the account is granted at the
  deployment (the §6 admission floor in `effective_scope_role`).
- **Viewer capability flags** — `GET /api/projects/<id>/collections` carries a
  `viewer` block (`can_create_collection`, `can_invite`, `role`); `GET
  /api/projects/<id>/collections/<cid>` carries `viewer.can_contribute /
  can_invite / role`. These drive the role-aware UI without a second round-trip.
- **Role-aware empty states (§22 C.3.3–C.3.5)** — a project Owner landing on an
  empty project sees a "Create your first collection" CTA (opening the
  create-collection modal — surfacing the S2 endpoint, previously UI-less); a
  contributor without create rights sees the bare empty directory; a collection
  contributor landing on an empty collection sees "Propose the first entry"
  (anonymous readers keep the S2 sign-in prompt). The directory also gains an
  Owner-only **Members** control opening the invitation modal.

Upgrade steps:

1. No operator action is required. S4 adds endpoints and UI only — there is no
   migration and no change to existing authorization outcomes. A deployment that
   was managing `memberships` rows directly (the S3 administrative path) keeps
   working; the new UI is an additional way to write the same rows.

## 0.42.0 — 2026-06-05

**Minor (breaking — upgrade steps below) — §22 three-tier refactor, slice S3:
*scope-role enforcement + collection-grain visibility.* Authorization is now
resolved by the four-layer most-permissive union of §B.2 — global → project →
collection → per-entry — over the unified `{owner, contributor}` roles, with the
§22.5 visibility gate enforced at the **collection** grain. A collection can be
"hidden from public existence" (`gated`): invisible to anonymous viewers and
omitted from the project directory, yet readable and listed for any contributor
holding a scope role that reaches it (collection / project / global). A
collection's visibility may be set only as strict or stricter than its project's.
Completes acceptance scenarios `@S3` (C1.1–C1.8: role usage, inheritance, the
most-permissive union, and no-negative-override).**

See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
(Part B roles, Part C.1 scenarios, Part E slice S3). The invitation UI and
role-keyed empty states remain S4; grants in S3 are applied administratively
(via the `memberships` table / an Owner-authorized create surface).

Added:

- **Four-layer scope-role resolver** — `effective_scope_role(user, collection)`
  folds a deployment owner/admin (global Owner), an explicit global-scope grant,
  a project-scope grant, and a collection-scope grant into the most-permissive
  unified role over a collection. Owner outranks RFC Contributor; there is no
  negative override (a child scope can never subtract a parent grant).
- **Global-scope grants** — migration 030 extends `memberships.scope_type` to
  `{global, project, collection}`. A "global RFC Contributor" (writes in every
  collection of every project, distinct from a deployment owner) is a
  `scope_type='global'` row (sentinel `scope_id='*'`).
- **Collection-grain visibility enforcement** — `can_read_collection` /
  `require_collection_readable` gate the collection-scoped read, entry, and
  propose routes; the project collection-directory (`GET
  /api/projects/<id>/collections`) is now viewer-aware (a hidden/gated collection
  is listed only for a scope-role holder; `unlisted` stays omitted from
  enumeration). The effective read gate is the stricter of the project's and the
  collection's visibility.
- **Collection visibility strictness** — a collection may narrow but never widen
  its project's visibility (`public` < `unlisted` < `gated`). Enforced at
  create-collection (422 on a looser request) and clamped at the registry mirror.
- **create-collection authority widened (§B.1)** — `POST
  /api/projects/<id>/collections` now admits a project-scope or global-scope
  grant holder (Owner **or** RFC Contributor — the project-level "create a
  collection" affordance), not only a deployment owner/admin. A collection-scope
  grant cannot create sibling collections.

Changed (breaking):

- **Write standing now requires an explicit scope grant outside the default
  collection.** The pre-three-tier implicit-on-public write baseline (a granted
  deployment `contributor` may propose on any public project with no membership
  row) is **narrowed to the migration-seeded `default` collection only** (the N=1
  case, §22.13). On every *explicitly-created* collection — and on every project
  beyond the default — proposing, discussing, branching, and contributing now
  require an explicit `{owner, contributor}` grant at the collection, its
  project, or global scope. Reads of public collections are unchanged.
- Entry-scoped authority checks (mark-reviewed, graduate, branch read/contribute,
  PR/discussion/contribution moderation) are re-pointed from the project grain to
  the entry's **collection** grain, so a collection Owner administers exactly
  their collection's subtree and no more.

Upgrade steps:

1. The schema migration (`030_global_scope.sql`) runs automatically on deploy and
   is backward-data-compatible — existing `memberships` rows are preserved. No
   operator action is required for the migration itself.
2. **A single-collection (N=1) deployment needs no further action.** The `default`
   collection keeps the implicit-on-public write baseline, so existing granted
   contributors keep proposing exactly as before.
3. **A deployment that has created additional collections (S2) MUST grant scope
   roles to its contributors.** Any contributor who should write in a non-default
   collection (or in a second project) now needs an explicit grant: a
   `memberships` row at `scope_type` `collection` (that collection), `project`
   (its project — covers every collection within), or `global` (`scope_id='*'` —
   every project). A deployment owner/admin is unaffected (global Owner by role).
4. To make a collection **hidden from the public**, set `visibility: gated` in its
   `.collection.yaml` (or the project's `visibility` in `projects.yaml`); the
   value MUST be as strict or stricter than the parent project's. The mirror
   clamps a looser value and logs a warning.

## 0.41.0 — 2026-06-05

**Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a
second collection.* A deployment can now host more than one RFC collection per
project: a second collection is created, navigated, and proposed into beside the
default one. Purely additive — a single-collection deployment is unchanged (its
`/p/<project>/` still redirects into the sole collection, C3.7/C3.8). Completes
acceptance scenario `@S2` (C3.6: an anonymous reader of an empty public
collection sees an empty catalog with no propose action and a sign-in prompt).**

See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
(Part E slice S2) and the slice plan
[`docs/design/plans/2026-06-05-s2-second-collection.md`](./docs/design/plans/2026-06-05-s2-second-collection.md).
Scoped {owner, contributor} roles at the collection axis remain S3; the
role-keyed create/propose-first empty states remain S4.

Added:

- **Named collections via `.collection.yaml`** — the registry mirror walks each
  project's content repo and upserts a collection per
  `<subfolder>/.collection.yaml` manifest (`type`, optional `visibility` /
  `initial_state` / `name`; `type` immutable per §22.4a, visibility inherits the
  project's when omitted). The default collection still flows from
  `projects.yaml`.
- **create-collection** — `POST /api/projects/<id>/collections` (deployment
  owner/admin). The bot commits a `.collection.yaml` to the content repo's
  `main`, then the registry re-mirrors so the `collections` row appears (the
  registry stays the source of truth, §22.2). New reads
  `GET /api/projects/<id>/collections` and `…/collections/<cid>`.
- **Collection-scoped serve + propose** —
  `GET /api/projects/<id>/collections/<cid>/rfcs[/<slug>]` and
  `POST …/collections/<cid>/rfcs/propose`. A propose writes the entry under the
  target collection's `<subfolder>/rfcs/`.
- **Collection directory at `/p/<project>/`** — lists the project's visible
  collections, or redirects into the sole one when there is exactly one
  (preserving the S1 single-collection UX). The catalog rail + entry views read
  the active `/c/<collection>/` segment and scope to it.

Changed:

- **The corpus mirror is collection-grained** — `cache.refresh_meta_repo`
  iterates each project's collections and reads each collection's
  `<subfolder>/rfcs/`, keying `cached_rfcs` by `collection_id`. The default
  collection keeps the shipped repo-root `rfcs/` path; N=1 serving is unchanged.

> ### Upgrade steps (0.40.0 → 0.41.0)
>
> - No required operator action — the slice is additive and the default-collection
>   paths are unchanged. A deployment **MAY** deploy this version with no config
>   change and keep running exactly as on 0.40.0.
> - To add a second collection, a deployment owner/admin **MAY** call
>   `POST /api/projects/<id>/collections` (or commit a `<subfolder>/.collection.yaml`
>   to the content repo directly); the registry mirror picks it up on the next
>   refresh.
> - Deployments that pin the framework version **MUST** bump their version pin to
>   `0.41.0`.

## 0.40.0 — 2026-06-05

**Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection*
grain. A deployment now hosts N projects, each owning one content repo and
holding N RFC *collections*, each collection a typed corpus. S1 inserts the
collection grain beneath today's project as the invisible default: the
deployment runs exactly as before, now with a real collection layer and one
extra `/c/<collection>/` URL segment. Per-corpus configuration (`type`,
`initial_state`), entry keys, and membership move to the collection grain;
no operator action is required beyond deploying.**

See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
(Part A model, Part E / §A.6 migration strategy). The structural model (Parts
A–D) is unaffected; the SPEC.md merge itself rides slice S6.

Added:

- **Migration `029_collections.sql`** — (1) a `collections` table
  `(id, project_id, type, subfolder, initial_state, visibility, name,
  registry_sha)` beneath `projects`; (2) the per-corpus fields (`type`,
  `initial_state`) move **down** off `projects` onto the collection; (3) one
  **default collection** per project (`id='default'` for the standard
  single-project deployment, `subfolder` = repo root), inheriting the project's
  type / initial_state / visibility; (4) the 13 entry-corpus tables re-key
  `(project_id, slug)` → `(collection_id, slug)` via the migration-028 rebuild
  pattern, each row mapped to its project's default collection; (5)
  `project_members` generalises into **`memberships(scope_type ∈ {project,
  collection}, scope_id, user_id, role, …)`** with the role enum collapsed to
  `{owner, contributor}` (M2's `project_admin`→`owner`,
  `project_contributor`→`contributor`; `project_viewer` folded into
  `contributor` this pass — the read-only tier is deferred).
- **`app/collections.py`** — collection resolution helpers
  (`default_collection_id`, `collection_type`, `collection_initial_state`,
  `project_of_collection`).
- **`/c/<collection>/` URL segment** — the canonical entry route is now
  `/p/<project>/c/<collection>/e/<slug>`. The project landing
  `/p/<project>/` redirects into the project's single (default) collection
  (C3.7); the deployment root `/` continues to redirect into the sole project
  (C3.8).

Changed:

- **The registry mirror** writes a project's grouping-tier fields (name,
  content_repo, visibility, config) to `projects` and the per-corpus fields
  (`type`, `initial_state`) to its default collection; §22.4a type-immutability
  is now enforced on the collection.
- **Backend threading** — `auth.project_of_rfc` recovers a project by joining
  `collections`; `auth.project_member_role` reads `memberships`;
  `cache`/`api_*`/`funder` writers + readers key the 13 entry-corpus tables by
  `collection_id` (the denormalised `project_id` tags on
  `cached_prs`/`threads`/`changes`/`notifications`/`actions`/`pr_resolution_branches`
  are unchanged). Serving stays project-scoped (collection = default); the
  registry `.collection.yaml` reader and collection-aware serving land in S2.

Breaking:

- **`/p/<project>/e/<slug>` URLs gain a `/c/<collection>/` segment.** The
  shipped v0.35.0 `/p/<project>/e/<slug>` form is preserved by a client-side
  redirect into the default collection; the pre-multi-project `/rfc/<slug>` and
  `/proposals/<n>` server 308s now target `/p/<default>/c/<default>/…`.

> ### Upgrade steps (0.39.0 → 0.40.0)
>
> - A deployment **MUST** deploy this version with its migrations applied (the
>   standard startup path runs `029_collections.sql` automatically); the
>   migration seeds the default collection and re-keys existing entries with no
>   data loss. No configuration change is required.
> - Operators **SHOULD** be aware that the canonical entry URL is now
>   `/p/<project>/c/default/e/<slug>`. Existing `/p/<project>/e/<slug>`,
>   `/rfc/<slug>`, and `/proposals/<n>` links keep working (client redirect /
>   server 308). External systems that hardcoded the old form **SHOULD** be
>   updated to the collection-scoped form at their convenience.
> - Deployments that pin the framework version **MUST** bump their version pin
>   to `0.40.0`.

Deferred (later slices): creating + navigating a second collection and the
registry `.collection.yaml` reader (S2); the four-layer scope-role resolver and
the `viewer` read tier (S3); invitation surfaces (S4); in-app create-project
(S5); per-type surfaces, membership lifecycle, and the SPEC.md merge (S6).

## 0.39.0 — 2026-06-04

**Minor — §22.13 step 1: the default-project-id re-stamp. A deployment can
move its original corpus off the bootstrap `default` id onto a meaningful slug
(e.g. `ohm`) so it lands at `/p/<id>/` and `default` is never a public URL.
No-op unless `DEFAULT_PROJECT_ID` is set to a non-`default` value.**

Added:

- **`projects.restamp_default_project(config)`** — at startup, after the
  registry mirror, if `DEFAULT_PROJECT_ID` resolves to a non-`default` id and
  bootstrap-stamped rows still exist, it renames `project_id` from `default` to
  the configured id across **every** project-scoped table (discovered by
  column, so it stays correct as the schema grows) and drops the stale
  `default` `projects` row (its data has moved to the configured row the
  registry mirror created). The rename runs with FK enforcement off — parent
  and child rows move together, so the composite FKs stay consistent — with a
  `foreign_key_check` backstop before commit. Idempotent.
- **Tests:** `test_restamp_default_project.py` — data + composite-FK children
  move to the new id, the stale row is dropped, FK integrity holds, the second
  call is a no-op, and an unset `DEFAULT_PROJECT_ID` leaves `default` in place.
  450 backend green.

Upgrade steps:

1. **MAY** set `DEFAULT_PROJECT_ID=<slug>` in the backend overlay and add the
   matching project (same `id`) to `projects.yaml`. On the next deploy the
   re-stamp moves the original corpus onto `<slug>` once; `default` URLs never
   become public. Leave it unset to keep the `default` id (no change).

## 0.38.0 — 2026-06-04

**Minor — §22 M3-backend Plan B (write path, propose): a new entry can be
proposed *into a specific project*, landing in that project's content repo and
surfacing under that project's proposals. A non-default project is no longer
read-only. No upgrade steps; single-project deployments are unaffected.**

Added:

- **`POST /api/projects/{pid}/rfcs/propose`** — propose into a chosen project
  (read-gated, then project-level contribute-gated, §22.6/§22.7). The propose
  body is now a project-parameterized helper; the unscoped `/api/rfcs/propose`
  stays as the default-project compat path. Slug uniqueness, the idea-PR
  reservation, the landing state (§22.4b), and the `proposed_use_cases` row are
  all scoped to the target project.
- **`GET /api/projects/{pid}/proposals`** — pending idea-PRs scoped to one
  project.
- **Per-project PR mirror** (`app/cache.py`): `refresh_meta_pulls` iterates
  every project's `content_repo`, stamping `cached_prs.project_id` (was: the
  default project only). `projects.content_repo(pid)` helper added.
- **Tests:** `test_project_scoped_propose.py` — propose into a second project
  lands in its content repo + shows only under its proposals (not the
  default's); gated-project propose 404s a non-member. 447 backend green.

Changed:

- **Frontend:** `api.proposeRFC(projectId, …)` / `listProposals(projectId)`;
  `ProposeModal` takes a `projectId`; `App` resolves the current project from
  the `/p/<id>/` URL so the propose modal targets it; `Catalog` lists that
  project's proposals.

Known limitation (next slice): the **edit** write flows — branch / PR /
graduation — and the **default-project-id re-stamp** (§22.13 step 1) are not
yet project-scoped (they still target the default project's content repo). Per
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3.

## 0.37.0 — 2026-06-04

**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
second project's corpus now renders under `/p/<id>/`, isolated by its own slug
namespace. No upgrade steps; single-project deployments are unaffected.**

This is the slice that makes "multiple projects" *observable*: M3-frontend
(v0.35.0) shipped the shell with a "not served" guard for any non-default
project; v0.36.0 rebuilt the keys so project #2 can exist; this serves project
#2's corpus.

Added:

- **Per-project read endpoints** (`app/api.py`): `GET /api/projects/{pid}/rfcs`
  (catalog scoped to one project) and `GET /api/projects/{pid}/rfcs/{slug}`
  (entry by `(project_id, slug)`), both behind the §22.5 read gate
  (gated project 404s a non-member). The unscoped `/api/rfcs[/{slug}]` remain as
  the default-project compat path.
- **Per-project corpus mirror** (`app/cache.py`): `refresh_meta_repo` now
  iterates every `projects` row and mirrors each project's `content_repo` into
  `cached_rfcs` stamped with that `project_id` (was: the default project only).
- **Tests:** `test_project_scoped_serving.py` — catalog scoping, entry
  isolation (same slug under two projects resolves distinctly; a slug present
  only in one 404s under the other), gated-project 404. 445 backend green.

Changed:

- **Frontend** reads the scoped routes: `api.listRFCs(projectId)` /
  `getRFC(projectId, slug)`; `Catalog` + `RFCView` pass `useProjectId()`. The
  **M3-frontend `NotServedPlaceholder` guard is removed** — every registry
  project renders its corpus. `ProjectLayout` keeps the 404/not-readable branch.

Known limitation (next slice): the **write** path (propose / branch / PR /
graduate) and the **default-project-id re-stamp** (§22.13 step 1) are not yet
project-scoped — write affordances still target the default project, so a
non-default project is effectively read-only until Plan B's write slice. (No
live impact: no deployment runs a second project yet.) Per
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3 (write).

## 0.36.0 — 2026-06-04

**Minor — §22 M3-backend Plan B (1/2): the slug-keyed PK/UNIQUE rebuild that
activates project #2. No behavior change (deployments are still single-project
`default`); migration `028` runs automatically on deploy.**

This lands the table rebuilds migration 026 deliberately deferred "until a
second project exists" — folding `project_id` into the slug-keyed PRIMARY KEY /
UNIQUE constraints so two projects can hold the same slug without colliding.
Shipped separately from per-project RFC *serving* (Plan B 2/2) for migration
hygiene: the schema change deploys and is verified on its own, smaller blast
radius.

Added:

- **Migration `028_project_scoped_keys.sql`** — rebuilds 13 tables to composite
  slug keys: `cached_rfcs` PK `(slug)` → `(project_id, slug)`; the `UNIQUE`/PK
  on `cached_branches`, `branch_visibility`, `branch_contribute_grants`,
  `stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`,
  `rfc_collaborators`, `contribution_requests`, `proposed_use_cases` all gain
  `project_id`; and the FKs **to** `cached_rfcs(slug)` on `rfc_invitations`,
  `rfc_collaborators`, `contribution_requests` become composite
  `(project_id, rfc_slug) → cached_rfcs(project_id, slug)`. (`cached_prs` is
  unchanged — `(repo, pr_number)` is already globally unique.)
- **Migration-runner capability** (`db.run_migrations`): a migration whose
  first line is `-- migrate:no-foreign-keys` runs with `PRAGMA foreign_keys`
  toggled OFF around it (required by SQLite's table-rebuild procedure, and a
  no-op inside a transaction) and a `PRAGMA foreign_key_check` after that fails
  the migration loudly on any dangling reference.

Changed:

- The `ON CONFLICT(...)` upsert targets for the rebuilt tables gain `project_id`
  (`cache.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`,
  `api.py`, `funder.py`) so they continue to match the new composite indexes.
  The inserted `project_id` still defaults to `default`, so behavior is
  identical for a single-project deployment.

Upgrade steps:

1. **MUST** deploy. Migration `028` runs automatically at startup; it rewrites
   the listed tables in one transaction with FK enforcement off and verifies
   `foreign_key_check` after. No data is dropped (rows already carry
   `project_id` from migration 026, M1). No config change.
2. **MAY** note: per-project RFC *serving* (path-scoped endpoints + the
   default-id re-stamp + the frontend guard removal) is the next slice (Plan B
   2/2), per `docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md`.

## 0.35.0 — 2026-06-04

**Minor (breaking) — §22 M3 frontend: `/p/<project>/` routing, runtime
branding (the `VITE_APP_NAME` hard cut), the deployment directory + project
switcher, and server-side 308 redirects off the old corpus-root URLs.
Completes the runtime-config cut 0.33.0 began.**

Added:

- **`DeploymentProvider`** (`src/context/DeploymentProvider.jsx`) — boots
  `GET /api/deployment` once and provides `{ name, tagline,
  defaultProjectId, projects[], loading }` to the tree. The neutral
  `brandTitle()` fallback (`'RFC'`) paints during the pre-fetch frame.
- **`/p/:projectId/*` routing** with the generic `/e/<slug>` entry segment
  (§22.10). **`ProjectLayout`** (`src/components/ProjectLayout.jsx`) fetches
  `GET /api/projects/:id`, applies the project's `theme` as `:root` CSS
  custom-property overrides (reset on switch/unmount so accents never bleed),
  provides `ProjectContext`, and sets the tab title to the project name.
- **The §4 guard** — `ProjectLayout` renders the corpus only for the
  corpus-served (default) project; any other id renders a "content not yet
  served" placeholder (`NotServedPlaceholder`), so per-project serving (the
  next backend slice, Plan B) can land without a wrong-content footgun.
- **Deployment directory** at `/` (`Directory.jsx`) — renders the
  caller-visible projects as cards when **2+** are visible; the **N=1** case
  redirects straight into the single project (`/p/<id>/`), preserving OHM's
  "land in the corpus" UX. A **project switcher** (`ProjectSwitcher.jsx`)
  rides deployment chrome when 2+ projects are visible.
- **Entry-noun terminology** (RFC / Spec / Feature) driven by `project.type`
  (§22.4a); the route segment stays the generic `/e/`.
- **`GET /api/deployment`** now returns **`default_project_id`** — the
  corpus-served project the frontend guard keys on.
- **Server-side 308 redirects** (`app/api_deployment.py`): `GET /rfc/<slug>`,
  `/rfc/<slug>/pr/<n>`, and `/proposals/<n>` permanently redirect to
  `/p/<default>/e/<slug>[…]` / `/p/<default>/proposals/<n>` (§5/§22.10), so
  external "RFC-0001"-style links and bookmarks keep working.

Breaking:

- **`VITE_APP_NAME` is removed.** The build no longer reads it and no longer
  fails without it; the deployment name comes from the registry
  (`deployment.name` in `projects.yaml`) served at runtime via
  `GET /api/deployment`. The same build now serves any deployment. The
  build-time `%VITE_APP_NAME%` HTML token and the `inject-app-name` Vite
  plugin are gone; `index.html` ships a static `<title>RFC</title>` and JS
  sets the real title after config loads.
- **Entry/proposal URLs moved under `/p/<project>/`.** The old SPA routes
  `/rfc/:slug`, `/rfc/:slug/pr/:n`, `/proposals/:n` are removed from the SPA
  and served as backend 308s instead — so nginx must route `/rfc/` and
  `/proposals/` to the backend rather than the SPA `index.html`.

Scope boundary (informational, not breaking): RFC *data* is still served
unscoped for the default project this slice — per-project corpus serving is
the next backend slice (Plan B). Non-default projects show the placeholder.

Upgrade steps:

1. **MUST** update nginx: add `location /rfc/` and `location /proposals/`
   blocks that `proxy_pass` to the backend, **before** the SPA `location /`
   fallback. The framework's `deploy/nginx/ohm.wiggleverse.org.conf` (and
   `testing/web.nginx.conf`) already carry them; a deployment running a custom
   vhost MUST add them, or old corpus-root URLs 404 against the SPA instead of
   redirecting.
2. **MUST** ensure the registry `projects.yaml` `deployment.name` is set — it
   is now the header brand and tab title (already required since 0.33.0).
   `tagline` shows on the directory.
3. **SHOULD** remove `VITE_APP_NAME` from `frontend/.env`; it is no longer
   read (no error if left — simply ignored).
4. **MUST** rebuild the frontend and deploy. Verify: the header shows the
   deployment name from `/api/deployment`; `/` lands in your project (N=1) or
   shows the directory (2+); an old `/rfc/<slug>` URL **308**-redirects to
   `/p/<default>/e/<slug>`; `/api/health` is green.
5. **MAY** note: the Tier-1 Playwright e2e for these flows lands once the
   Tier-1 Docker stack seeds a registry repo + `projects.yaml` (`REGISTRY_REPO`
   is unset in `testing/.env.tier1` today, so the dockerized backend can't boot
   there post-0.33.0). This slice is covered by Vitest unit tests
   (`DeploymentProvider`, `ProjectLayout` theme/guard, `Directory`) + the
   backend redirect tests (`backend/tests/test_api_deployment.py`).

## 0.34.0 — 2026-06-04

**Minor — containerize rfc-app for per-PR preview environments (flotilla
SPEC §15). Additive: production is unaffected — it still deploys via the
pin-based on-VM gesture. No upgrade steps for existing deployments.**

Adds a `Dockerfile` (+ `deploy/preview/`) so the operator's flotilla can build
a PR's tree and run it as an ephemeral, scale-to-zero Cloud Run preview with a
seeded **synthetic** database and **zero real secrets** (test-secret env only):

- `Dockerfile` — multi-stage: Vite SPA build → Python runtime serving the SPA
  via nginx on `$PORT` and reverse-proxying `/api/`,`/auth/` to a single-process
  uvicorn on `127.0.0.1:8000` (mirrors the prod nginx + systemd split, minus
  TLS, minus prod secrets). Single process, single SQLite file (§4.2).
- `deploy/preview/entrypoint.sh` — renders nginx against Cloud Run's `$PORT`,
  boots uvicorn (which runs migrations), and applies the synthetic seed on a
  fresh DB.
- `deploy/preview/seed.sql` — version-controlled synthetic fixture (no PII).
- `deploy/preview/preview.env.example` — the test-secret env shape (Cloudflare
  always-pass Turnstile keys, a Mailpit SMTP sink, analytics no-op'd) the
  operator loads into flotilla's preview overlay layer.

The framework still knows nothing about flotilla — the image is a generic
container of rfc-app; flotilla is one possible orchestrator of it.

## 0.33.0 — 2026-06-04

**Minor (breaking) — §22 M3 backend Plan A: project registry mirror +
runtime config. The framework now learns its projects from
`REGISTRY_REPO/projects.yaml`; `META_REPO` is retired. Migration `027`
runs automatically on deploy.**

Added:

- **Project registry mirror** (`app/registry.py`): the framework reads
  `projects.yaml` from `REGISTRY_REPO` and mirrors its `deployment:`
  block and `projects:` entries into the `projects` table + a new
  `deployment` singleton. The mirror runs at startup (reconciler) and
  on every §4 webhook push to the registry repo.
- **`GET /api/deployment`** — returns `name`, `tagline`, and the list
  of visible projects (each item carries `id`, `name`, `type`,
  `visibility`). Replaces the build-time `VITE_APP_NAME` as the
  authoritative runtime config source (the frontend cut lands in
  M3-frontend).
- **`GET /api/projects/:id`** — returns `id`, `name`, `tagline`, `type`,
  `visibility`, `initial_state`, and `theme` for a single project.
- **§22.4b `initial_state`** honored at propose time: new RFCs enter
  the state named by the project's `initial_state` field (default:
  `super-draft`; `bdd` projects default to `active`).
- **§22.4c `unreviewed` flag**: newly proposed RFCs are flagged
  `unreviewed = true`. Owners clear it via
  `POST /api/projects/:id/rfcs/:slug/mark-reviewed`. The catalog
  accepts `?unreviewed=true` to filter to the review queue.
- **Migration `027`** (additive): adds `projects.type` /
  `projects.initial_state`, the `deployment` table, and
  `cached_rfcs.unreviewed` / `reviewed_at` / `reviewed_by`.

Breaking:

- `META_REPO` is retired. The app **refuses to start** if `REGISTRY_REPO`
  is unset. The corpus mirror now reads each project's `content_repo`
  from `projects.yaml` rather than from the `META_REPO` env var.

Upgrade steps:

1. **MUST** create a registry repo under your deployment's Gitea org.
2. **MUST** author `projects.yaml` at its root with a `deployment:` block
   (`name`, `tagline`) and one `projects:` entry for your existing corpus:
   ```yaml
   deployment:
     name: <your deployment name>
     tagline: <your tagline>
   projects:
     - id: default
       name: <your project name>
       type: document
       content_repo: <your old META_REPO value>
       visibility: public
   ```
   Keep `id: default` for this release; the pretty-slug re-stamp lands in
   the next backend slice, before any `/p/` URL is public.
3. **MUST** set `REGISTRY_REPO=<your registry repo name>` and **MUST**
   remove `META_REPO` (or leave it unset — it is ignored but its presence
   may cause confusion).
4. **MUST** add a Gitea webhook on the registry repo pointing at
   `/api/webhooks/gitea` (same secret as the corpus webhook).
5. **MUST** deploy. Migration `027` runs automatically at startup; the
   registry mirror reconciles immediately after. Verify
   `GET /api/deployment` returns your project and `/api/health` is green.
6. **SHOULD** rebuild the frontend (no new env var is required until
   M3-frontend lands, but the frontend currently still reads
   `VITE_APP_NAME` for the display name).

## 0.32.0 — 2026-06-01

**Minor — graduation's integer RFC number is now optional, and RFC
owners + site owners can retire (soft-delete) RFCs. Schema migration
`025_retired_state.sql` runs automatically on deploy; a frontend rebuild
applies the UI.**

Two changes to the §13 lifecycle:

1. **Optional number at graduation (§13.2/§13.3).** Graduation no longer
   hard-requires a valid `RFC-NNNN`. The Graduate dialog's integer-ID
   field is now optional: it is still pre-filled with the next free
   number as a *suggestion*, but the graduating owner may clear it and
   graduate with **no number**. When blank, the entry flips to `active`
   with `id: null` and the **slug remains the canonical identifier**
   (§2.3). `GET …/graduate/check` treats a blank id as valid (`ok:true`);
   `POST …/graduate` accepts a blank/absent `rfc_id` and only validates
   the `^RFC-\d{4,}$` regex + collision check when a number *is* supplied.
   The catalog and RFC view render number-less active entries by their
   slug/title (no "RFC-undefined"). RFC-0001's existing number is
   grandfathered — `cached_rfcs.rfc_id` was already nullable, so no data
   migration was needed for this part.

2. **Retire / un-retire — soft delete (§3, §3.1, §13.7).** A fourth entry
   state, `retired`, is added. An RFC's own owners (frontmatter) and site
   `owner`-role holders — **not** app admins — may retire an entry
   (`POST /api/rfcs/<slug>/retire`); it flips to `retired` via an
   auto-merged meta-repo PR, leaving the body and every other field
   (including any integer id) intact. A retired entry is removed from
   **every** browsing surface: the catalog, the RFC/discussion/branch
   views (404), and link pickers/search. It is *not* hard-deleted — the
   entry stays in `rfcs/` (git is truth). Un-retire
   (`POST /api/rfcs/<slug>/unretire`) restores the prior state and is
   **site-owner-only**, so a soft-delete is always recoverable by the
   operator but an RFC owner cannot reverse their own retirement. Site
   owners find retired entries via a new owner-gated admin surface
   (`GET /api/admin/retired-rfcs`, the "Retired" tab) and un-retire from
   there or by navigating directly to the entry.

Upgrade steps:

- **MUST** run the migration. `025_retired_state.sql` rebuilds
  `cached_rfcs` to widen its `state` CHECK constraint to include
  `retired` (SQLite cannot alter a CHECK in place). It runs automatically
  at startup via the forward-only migration runner; existing rows are
  preserved, and the table is reconstructible from Gitea by the
  reconciler regardless (§4). No operator action beyond a normal deploy.
- **SHOULD** rebuild the frontend so the optional-id Graduate dialog, the
  Retire affordance, and the owner-only "Retired" admin tab are present.
- No config or secret changes.

## 0.31.4 — 2026-06-01

**Patch — bug fix + UI polish: secondary buttons that were invisible on
light surfaces now render legibly, and the RFC view's breadcrumb action
bar is harmonized into one coherent control group. CSS-only
(`frontend/src/App.css`); no schema, API, config, overlay, or secret
change — a plain frontend rebuild applies it. No upgrade steps. Shipped
from driver session 0059.0.**

`.btn-link` was authored as a *dark-header* utility — white text on a
translucent-white fill (`rgba(255,255,255,0.15)`), the established
on-dark pattern for the app header's "Sign out". But the same class is
reused on **light** surfaces: the RFC breadcrumb action bar
(`RFCView.jsx`), the PR view's diff-mode toggle and "Edit title" control
(`PRView.jsx`), the invitations and inbox modals, and the discussion
panel. On those near-white backgrounds the buttons were white-on-white —
present in the DOM, fully functional, but visually invisible. The
reported symptom: on a super-draft's header, "Metadata", "Claim
ownership", and "Invitations" looked *missing*, while the filled CTAs
("Start Contributing", "Graduate to RFC repo") rendered fine because
their fill carried them.

The fix is root-cause, not a per-site patch:

- The base `.btn-link` rule is now a proper light-surface secondary
  button (white fill, hairline `--c-gray-300` border, `--c-gray-700`
  label, hover darkens both). This corrects every light-surface reuse at
  once.
- The original translucent-on-dark treatment is preserved for the one
  legitimate dark-surface use via an `.app-header .btn-link` scope, so
  the header "Sign out" is unchanged.
- The breadcrumb action bar (`.breadcrumb-actions`) normalizes every
  action — the discuss/contribute toggle, the filled CTAs, and the
  secondary buttons — to one height, radius, and type scale, so the row
  reads as a single intentional control group. The bar now `flex-wrap`s
  instead of clipping buttons off the right edge when the set is wide.
- The diff-mode toggle's active option now reads as clearly selected
  (filled ink) rather than relying on a weight change alone.

Smooth hover transitions and the keyboard focus ring were already
provided globally by the v0.21.0 interaction-polish layer, so this
change adds no new motion or focus rules — it only corrects resting-state
color/contrast and harmonizes sizing.

## 0.31.3 — 2026-05-30

**Patch — admin Users tab: "Last seen" reads "Never" for unclaimed
invites. Visual/logic only in `Admin.jsx`. A plain frontend rebuild
applies it.**

An admin-created invite row showed a real-looking "Last seen" timestamp
identical to "Signed up," implying the invitee had visited when they
hadn't. Cause: `users.last_seen_at` is `NOT NULL DEFAULT (datetime('now'))`
(`migrations/001_users_and_audit.sql`) and the invite INSERT
(`invites.py`) sets neither timestamp, so both default to the
row-creation instant; `last_seen_at` only advances on a real
authentication. Since an unclaimed invite has provably never
authenticated (that unclaimed state is exactly what drives the
"PENDING INVITE" badge), the Users tab now renders **"Never"** for the
Last-seen cell of a pending-invite row instead of the misleading
default. Signed-up (the invite-created date) is unchanged.

Upgrade steps: none. **SHOULD** deploy as a normal code deploy.

## 0.31.2 — 2026-05-29

**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
`App.css` (`.welcome`). A plain frontend rebuild applies it.**

The welcome read-view was jammed against the catalog divider with no
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
carries a bare `.main-pane { padding: 0; display: flex }` override (the
§8 three-column RFC shell) that shadows the earlier padded read-view
rule, so the pane provides no padding — and `.welcome` (just
`max-width`) never compensated. The welcome surface now owns its own
breathing room: 56px top / 48px side gutters, a capped 680px measure,
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
rhythm at `--leading-relaxed`. Applies to both the signed-out and
signed-in welcome (same `.welcome` class).

Upgrade steps: none. **SHOULD** deploy as a normal code deploy.

## 0.31.1 — 2026-05-29

**Patch — admin Users tab + header UX polish. Visual only: CSS plus
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
secret change). A plain frontend rebuild applies it.**

Two latent CSS defects fixed:

- **`.invite-badge` had no rule.** The "(pending invite)" marker on
  admin-created-but-unclaimed user rows rendered as bare parenthetical
  text. It's now a quiet amber pill, consistent with the other status
  badges.
- **`.btn-link-quiet` never reset native button chrome.** Used as a
  bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
  close ×, and link-buttons in Login / BetaPending), it kept the
  browser's default grey button box. The reset that the `.otc-login`
  scope already carried is folded into the base rule, so every
  `btn-link-quiet` is now a true quiet link.

Users-tab cleanups, all token-based:

- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
  two lines); timestamps render as an intentional date-over-time stack
  instead of a ragged mid-value wrap; the duplicated email in a row's
  subline (the handle already *is* the email when there's no Gitea
  login) is de-duplicated; the "Create user + invite" action moves
  flush-right beside the title; inline DB-column references in the
  intro copy read as quiet chips.

Header:

- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
  light-surface treatment — a `gray-200` border and a `gray-50` hover —
  that rendered as a pale box in the nav and went white-background /
  white-icon (invisible) on hover. It now speaks the nav-link
  vocabulary (`.header-about` et al.): borderless, `gray-300` icon
  brightening to white on a faint translucent hover, unread badge
  unchanged.

Upgrade steps: none. **SHOULD** deploy as a normal code deploy.

## 0.31.0 — 2026-05-29

**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
longer graduate into their own Gitea repositories; every RFC lives in
its meta-repo entry (`rfcs/<slug>.md`) for its whole life, and
graduation is an in-place `super-draft → active` state flip that keeps
the body in the entry. The per-RFC-repo machinery — repo creation,
`RFC.md`/`README.md`/`.rfc/metadata.yaml` seeding, body-strip, the
five-step transactional sequence and its rollback — is removed. This is
the framework change behind a much simpler deployer story: one content
repository, every RFC under `rfcs/`.**

What changed, concretely:

- **Graduation is a single flip.** `POST /api/rfcs/<slug>/graduate` now
  opens one meta-repo PR that re-serializes the entry with
  `state: active`, the integer `id`, and `graduated_at`/`graduated_by`
  — **body unchanged, `repo` left null** — then auto-merges it. No repo
  is created, nothing is seeded, and there is no rollback (an open- or
  merge-failure leaves the entry a super-draft; a failed merge's PR and
  branch are cleaned up). The Graduate dialog drops the **Repo name**
  field (two fields now: integer ID + owners) and the progress stack is
  two steps (`open_pr`, `merge_pr`).
- **Active RFCs edit on the meta repo.** Branch/PR/chat dispatch keys on
  meta-residency (`repo IS NULL`) rather than `state == 'super-draft'`,
  so an active RFC's branches, body-edit PRs, threads, flags, and
  `changes` all live on the meta repo exactly as a super-draft's do.
  `promote-to-branch` names an active RFC's auto-branch
  `edit-<slug>-<hex>` so the shared-repo cache can attribute it.
- **Open body-edit PRs no longer block graduation** (§9.8) — the body is
  kept, so they coexist with the flip.
- **`refresh_rfc_repo` and the per-RFC read path are dead** for
  meta-only entries (the reconciler only sweeps entries with a non-null
  `repo`, of which there are none after the fold-back below).

**Upgrade steps:**

- Deployments **MUST** fold any already-graduated per-RFC-repo RFC back
  into its meta entry before/with this deploy: restore the per-RFC
  `RFC.md` body into `rfcs/<slug>.md`, set `repo: null` (keep
  `state: active` and the integer `id`), and archive the per-RFC repo.
  An entry left with a non-null `repo` keeps using the retained legacy
  read path, but **no new** per-RFC repos are ever created. For OHM,
  RFC-0001 `human` was folded back in driver session 0041.0 (§13.6).
- No schema migration, no new config, no new secret, no overlay change.
  A plain code deploy applies it; the running reconciler reconciles the
  catalog on its next sweep (≤5 min).
- The `repo:` frontmatter field and the `/api/rfcs/<slug>/blocking-prs`
  endpoint are **retained** (the field for schema stability + legacy
  entries; the endpoint as an informational, non-blocking surface), so
  no client contract is removed — `graduate/check` simply no longer
  returns a `repo` field and never reports `blocking_prs` as a gate.

## 0.30.2 — 2026-05-29

**Patch — header nav label: the persistent chrome link reverts from
"Philosophy" back to "About." Display text only — the route
(`/philosophy`), the `header-about` class, and the page itself are
unchanged. A plain frontend rebuild applies it; no schema, API, config,
overlay, or secret change.**

The §14.3 persistent link was relabeled "About" → "Philosophy" in
v0.21.0. This restores "About" as the neutral, framework-native label
(the CSS class `header-about` and the surrounding comment already call
it "the About link"). A deployment that wants a more pointed framing
can title its own About page in the `PHILOSOPHY.md` content the
`PHILOSOPHY_PATH` override serves.

Upgrade steps: none. **SHOULD** deploy as a normal code deploy.

## 0.30.1 — 2026-05-29

**Patch — bug fix: a merged idea-PR whose branch was deleted no longer
lingers as a phantom "pending idea." No schema, API, config, overlay,
or secret change — a plain code deploy applies it, and the running
reconciler clears any existing ghost on the next sweep (≤5 min) once
deployed. Shipped from driver session 0040.0.**

`refresh_meta_pulls` (and `refresh_rfc_repo`) recover a PR's slug/kind
by parsing its Gitea `head.ref`. When a PR is merged **and its branch
deleted**, Gitea stops reporting the real branch name and returns the
synthetic `refs/pull/<N>/head` sentinel instead. The slug then parsed
to `None`, the reconcile loop skipped the row, and `cached_prs.state`
stayed frozen at `open` forever — so the entry showed as **both** a
super-draft (the `cached_rfcs` push-event reconcile succeeded) **and** a
pending idea (the `cached_prs` PR-close reconcile never landed). The fix
recovers the original branch name from the already-stored `cached_prs`
row (which retains the real `head_branch` from when the PR was open;
migration 002) whenever Gitea reports an empty or `refs/pull/` sentinel
ref. Regression test added in `test_propose_vertical.py`
(`test_merged_idea_pr_with_deleted_branch_clears_proposal`).

Surfaced through the ROADMAP #35 operator authoring lane, which merges
idea PRs from the CLI with branch-deletion enabled — a path the web UX
never exercises (it leaves branches in place, so
`default_delete_branch_after_merge` stays false). The framework should
not depend on branches outliving their merge, hence the framework-level
fix rather than a tooling workaround.

Upgrade steps: none. **SHOULD** deploy as a normal code deploy; the
periodic reconciler self-heals any existing phantom on its next sweep.

## 0.30.0 — 2026-05-29

**Minor — documentation: the user guide (`DOCS.md`, served at
`/docs/user-guide` via `/api/docs`) brought back in sync with the
shipped app. No code, schema, API, config, overlay, or secret change —
a plain code deploy serves the updated guide. Shipped from driver
session 0037.0.**

The guide had drifted since it was first written: it still described
the pre-OTC email *allowlist* sign-in, listed four propose-RFC fields,
and predated several shipped surfaces. Updated to match v0.7.0–v0.29.0:

- **Signing in** rewritten for the email + one-time-code flow (v0.7.0),
  optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
  optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
  `pending` → admin-`granted` gate (v0.8.0 / #6), plus the admin-create
  + invite-claim path (v0.17.0 / #16). The vestigial allowlist is no
  longer described as the gate.
- **Proposing a new RFC** now lists five fields, adding the optional
  "What will you be using this RFC for?" use-case field (#26) and noting
  the AI tag-suggestion disclosure (#27).
- **Roles & permissions** documents the `pending` state.
- New **Invitations, cross-references, and contribution requests**
  section covers owner invitations (#12) and the RFC auto-link /
  create-RFC / ask-to-contribute affordances (#28).
- New **Privacy and cookies** section covers the consent banner and
  consent-gated analytics (#11 / #13).

Upgrade steps: none — documentation-only; the change is the `DOCS.md`
file served verbatim by `/api/docs`. A plain code deploy at this tag
serves it. A deployment that overrides the guide via `DOCS_PATH`
supplies its own copy and is unaffected.

## 0.29.0 — 2026-05-28

**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
candidate terms, and offer-to-contribute-to-a-pending-RFC. One auto-
applied migration (024, additive: a new `contribution_requests` table).
No config/overlay/secret change; no nginx/systemd change. A plain code
deploy + the auto-migration picks it up. Shipped from driver session
0033.0.**

Both parts extend the v0.26.0 (#28 Part 1) read-time scanner
(`backend/app/rfc_links.py`) and its renderer
(`frontend/src/components/LinkedText.jsx`). The scanner now sorts each
matched term into one of three buckets — active link (Part 1, unchanged),
pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — in one
pass, with precedence active > pending > candidate at any position. The
backend still emits only structured segments (never HTML), so the surface
stays XSS-safe by construction.

- **Part 2 — create-RFC offers.** A *strong-candidate* term — a
  **multi-word tag** from the #27 tag taxonomy that has no defining RFC
  (no active or super-draft RFC whose slug/title is that term) — renders,
  for a viewer with create rights (`permission_state='granted'`), as an
  inline "+ create RFC" affordance. Clicking opens the propose-RFC modal
  with the term pre-filled as the title (`ProposeModal` gained an
  `initialTitle`; the affordance routes via `?propose=<term>`, read in
  `App.jsx`). The heuristic is deliberately conservative — multi-word is
  the same false-positive guard the title rule uses, so a single common
  tag word (`identity`) is never offered. Broader candidate detection
  (capitalized phrases mined from text, terms repeated across recent PRs,
  or the #27 Haiku `ANTHROPIC_API_KEY` pathway) is a sanctioned but
  deferred extension.
- **Part 3 — contribute-to-pending offers.** A term matching a *pending*
  RFC — a super-draft (`state='super-draft'`: accepted as an idea, owned,
  with a contribution surface, not yet graduated) — renders, for a
  signed-in non-owner, as an inline "ask to contribute" affordance
  carrying the owner's display name ("<owner> is working on an RFC for
  '<term>'"). It opens a contribute-request form (`?contribute=<slug>`)
  with three fields — **who I am** (required), **why I'm asking**
  (required), **what I'd use it for** (optional, mirroring #26). Submitting
  lands a `contribution_requests` row and one actionable §15 inbox
  notification per owner (new kind `contribution_request_on_pending_rfc`,
  category `personal-direct` — so it reuses the existing
  `email_personal_direct` preference, no new toggle). In the inbox the
  owner sees the requester's who/why/use-case inline with **Accept** /
  **Decline**. Accept fires #12's owner-invite flow with the requester as
  the invitee (a `contributor` `rfc_invitations` row + the existing invite
  email) and echoes a notification back to the requester; Decline closes
  the request and notifies the requester. Pre-merge idea PRs (not yet in
  `cached_rfcs`, no contribution surface) are deliberately out of scope —
  a documented future extension.

New endpoints (all under the existing `/api` router):
`GET /api/rfcs/{slug}/contribution-target`,
`POST /api/rfcs/{slug}/contribution-requests`,
`POST /api/rfcs/{slug}/contribution-requests/{id}/accept`,
`POST /api/rfcs/{slug}/contribution-requests/{id}/decline`.

The owner-invite issue path was refactored into one reusable chokepoint,
`api_invitations.issue_invitation(...)`, shared by the manual invite
endpoint and Part 3's accept path so the dup-guard, token mint, insert,
and transactional email stay identical.

Upgrade steps:

1. Deployments **MUST** apply the auto-run migration `024` (additive: the
   new `contribution_requests` table; no existing table or row is
   touched). The standard deploy path runs pending migrations on start —
   no manual step beyond deploying the new code.
2. No config, overlay, or secret change is required. The Part 2 candidate
   affordance reuses #27's tag taxonomy; it surfaces only when the corpus
   carries multi-word tags without a defining RFC, and the create
   affordance renders only for beta-granted viewers. Part 3's email reuse
   sends through the existing invitation SMTP path — no new key.

## 0.28.0 — 2026-05-28

**Minor — security-hardening follow-up (Session-0026 audit, informational
findings I3 + I4). No operator action required: no migration, no schema
change, no config/overlay change, no API/behavior change for any caller.
A plain code deploy picks it up. Shipped from driver session 0032.0.**

Two informational findings from the Session-0026 audit, both
framework-internal defense-in-depth:

- **I3 — dead HTML-email branch guarded.** `email_envelope.build_envelope`
  accepted a `body_html=` argument that built a `multipart/alternative`
  body, but no send path ever passed it — every rfc-app mail is plain
  text. An unused branch that would emit HTML built from (potentially
  unescaped) user content is the C1 stored-XSS class waiting in the mail
  channel. The branch is now a loud guard: passing `body_html` raises
  `NotImplementedError`. The argument is kept in the signature for
  documented future symmetry; enabling HTML mail becomes a deliberate
  change that MUST HTML-escape user content at the call site and remove
  the guard in the same commit.
- **I4 — Turnstile siteverify no longer blocks the event loop.**
  `turnstile.verify_token` was a synchronous function issuing a blocking
  `httpx.post` from inside the async `/auth/otc/request` handler, so a
  slow CloudFlare response stalled the single worker for up to the 10s
  timeout. It is now `async` and awaits the call on an
  `httpx.AsyncClient` (matching the codebase's existing async-httpx
  pattern), isolated behind a narrow `_siteverify_post` seam. The sole
  caller (`main.py`) now `await`s it.

Upgrade steps: **none.** Both changes are internal. The `verify_token`
signature changed from sync to `async` (callers must `await`), but the
only caller is in-tree (`main.py`) and is updated in this release; no
deployment-facing surface, config key, or migration is affected.

## 0.27.0 — 2026-05-28

**Minor — security-hardening release (Session-0026 audit remediation).
One auto-applied migration (023); one behavior change that re-prompts
device-trust; deployments MUST re-apply the nginx + systemd files.**
This is the work cut as the "v0.25.0 security-hardening" branch; it
reversioned to 0.27.0 because v0.26.0 (#28) took the next slot while it
was in flight. Shipped from driver session 0030.0.

- **C1 (Critical) — stored-XSS closed.** Every markdown→HTML sink now
  routes through one chokepoint, `frontend/src/lib/sanitizeHtml.js`
  (DOMPurify), before any `innerHTML` / `dangerouslySetInnerHTML` write:
  `MarkdownPreview`, both `ProposalView` sinks (entry body +
  `proposed_use_case`), and `Editor`. A hook adds
  `rel="noopener noreferrer"` to `target=_blank` links. `marked` no
  longer passes raw HTML / `javascript:` URIs to the DOM, so a
  contributor can no longer plant a payload that runs in an admin/owner
  session during review.
- **H1 — OTC verify is rate-limited.** New `backend/app/ratelimit.py`
  (per-IP token buckets) gates `/auth/otc/verify`, `/auth/otc/request`,
  and the passcode check/verify paths; a per-account OTC-verify lockout
  (migration `023_otc_verify_lockout.sql`) mirrors the passcode lockout.
- **M1 — device-trust lookup no longer table-scans.** The device-trust
  cookie value is now `"<row_id>.<raw_token>"`; `device_trust.lookup`
  reads the one indexed row and bcrypt-checks only it, instead of
  bcrypt-checking every row in the table on each unauthenticated
  `/auth/device-trust/start`.
- **M2 — HTTP security headers** (CSP, HSTS, X-Frame-Options,
  X-Content-Type-Options, Referrer-Policy) added to the nginx server
  block. **L8/I1**: `server_tokens off` + legacy TLS1.0/1.1 removed.
- **M4 — session cookie `Secure` by default** (`SESSION_COOKIE_SECURE`,
  defaults on; a dev box on plain http sets it `false`).
- **M5 — bounce webhook fails closed.** An unset
  `WEBHOOK_EMAIL_BOUNCE_SECRET` now **disables** `/api/webhooks/email-bounce`
  (503) instead of leaving it open; a dev opts back in with
  `RFC_APP_INSECURE_BOUNCE_WEBHOOK=1`.
- **L2/L3** per-IP cooldown + check-endpoint throttle. **L4** systemd
  sandbox knobs (`CapabilityBoundingSet=`, `ProtectKernel*`,
  `RestrictAddressFamilies`, `SystemCallFilter`, …).

Upgrade steps:

1. **Migration** — none manual; `023_otc_verify_lockout.sql` auto-applies
   at startup via `db.run_migrations`.
2. **Device trust (MUST expect re-prompt)** — the cookie format changed,
   so existing "trusted device" cookies no longer match; affected users
   are re-prompted for device verification once. No data migration; old
   rows are simply never matched and age out.
3. **nginx + systemd (MUST apply out-of-band)** — the deploy gesture does
   **not** install `deploy/nginx/ohm.wiggleverse.org.conf` or
   `deploy/systemd/rfc-app.service`. After deploying the code, copy both
   to their system locations, then `nginx -t && systemctl reload nginx`
   and `systemctl daemon-reload && systemctl restart <unit>`. (M2 headers
   and L4 sandboxing do not take effect until this is done.)
4. **Bounce webhook (SHOULD)** — bind `WEBHOOK_EMAIL_BOUNCE_SECRET` (or
   set `RFC_APP_INSECURE_BOUNCE_WEBHOOK=1` for dev). Unset → the endpoint
   returns 503 (closed). No legitimate bounce source is wired today, so
   503 is the safe default.
5. **Session cookie (SHOULD, dev only)** — a deployment served over plain
   http MUST set `SESSION_COOKIE_SECURE=false` or the session cookie
   won't be sent. Production over HTTPS leaves it unset (Secure on).

## 0.26.0 — 2026-05-28

**Minor — no schema migration, no new secret, no config, no upgrade
steps. Additive read-time enrichment; deployments inherit it on deploy
with nothing to set.** Roadmap item #28 **Part 1**: references to existing
**accepted** RFCs inside PR descriptions and comment text now render as
inline links to the referenced RFC. Shipped from driver session 0029.0,
in parallel with the v0.25.0 security-hardening session — hence the
version-slot gap (0.25.0 is that session's; this took the next free slot
per the roadmap's "claims the next available version number" rule).

Parts 2 (offer-to-create-RFC for strong-candidate terms) and 3
(offer-to-contribute-to-a-pending-RFC) of item #28 are deliberately **not**
in this release — Part 1 ships first as the easy win, exactly as the
roadmap row anticipates.

- **Where it links.** The PR review page's description and review
  comments (`GET /api/rfcs/{slug}/prs/{n}`), and the PR-less per-RFC
  discussion comments (`GET /api/rfcs/{slug}/discussion/threads/{id}/messages`).
  Branch-chat (the AI-collaboration surface) is intentionally out of
  scope for Part 1 — a noted follow-up.
- **Read-time, not submit-time.** The roadmap row phrases the scan as
  happening "at submit/post time"; this ships it as **read-time**
  enrichment instead. The intent the row actually names — "not as live
  compose preview" — holds (drafts are never scanned, only submitted
  content on read). Read-time was chosen because (a) links track the
  *live* accepted-RFC set — a newly-accepted RFC starts linking in older
  comments, a withdrawn one stops linking everywhere — rather than
  freezing stale at submit; (b) it needs **no migration** (no derived
  data to persist); (c) the active-RFC corpus is small and cache-resident,
  so per-read scanning is cheap. (Recorded as a §19.3-rule-2 spec note in
  the session transcript.)
- **XSS-safe by construction.** The backend returns structured *segments*
  (a list of `{type:"text"}` / `{type:"rfc", slug, label, title}` items),
  never HTML. The new `LinkedText` frontend component maps segments onto
  React text nodes and anchors — no `dangerouslySetInnerHTML` — so a
  comment author cannot inject markup through this path. Every enriched
  field keeps its raw `text`/`description` alongside the `*_segments`, so
  any non-segment-aware caller is unaffected.
- **Conservative matching (false-positive-averse).** A reference links
  only when it is unlikely to be coincidental: an `rfc_id` token
  (`RFC-0001`), a **multi-word** title (`Open Human Model`), or a
  **hyphenated** slug (`open-human-model`). A single common-word title or
  slug (a hypothetical RFC titled "Human") is **not** auto-linked — that
  would turn every prose "human" into a link. Matching is case-insensitive,
  word-boundary-anchored, longest-match-wins, and suppresses an RFC's
  self-references inside its own PR/discussion. The roadmap's "curated
  canonical-terms list" remains an explicit future opt-in rather than a
  guessed-at default. New module: `backend/app/rfc_links.py`.

Tests: 12 new (`test_rfc_links_vertical.py`) — 9 scanner units (gating
rules, word boundaries, longest-match, case-insensitivity, casing
preservation, the rfc_id/multi-word/hyphenated gates) plus 3 end-to-end
(PR description + review comment + discussion comment all surface
`*_segments`; self-reference suppression). Full suite 363 green; frontend
builds clean.

Upgrade steps:

1. None. This release is purely additive: no migration, no new
   environment variable, no secret, no overlay. A deployment picks up RFC
   auto-linking the moment it deploys this version. (RFCs only link once
   they are in the `active` state — proposed/super-draft and withdrawn
   RFCs are never link targets, matching the §11.3 universal-public read
   rule.)

## 0.24.0 — 2026-05-28

**Minor — one new secret required before this version serves tag
suggestions; no schema migration; no behavior change for deployments
that do not bind the key.** Roadmap item #27: as a contributor fills in
the propose-RFC form, the backend asks Claude Haiku to recommend tags
drawn from the collection's existing tag set, surfaced as clickable
suggestion chips. Shipped from driver session 0025.0. This is the §9.1
"Slice 2" AI-suggested chips that the propose modal has carried a
deferred placeholder for since Slice 1.

- **`POST /api/rfcs/suggest-tags`** (contributor-gated, per-user
  rate-limited). Takes the partial draft (`title`, `pitch`,
  `use_case`) and returns `{ "suggestions": [{ "tag", "confidence" },
  …] }`. The model is constrained to the corpus's existing tags — v1
  tags are free-form chip input, so "the taxonomy" is the de-facto set
  of distinct tags the existing RFCs carry. The model MUST NOT invent
  tags; taxonomy extension is a deliberate out-of-scope follow-up.
- **Always Claude Haiku, for cost.** Tag suggestion uses Haiku
  regardless of the `ENABLED_MODELS` chat-picker universe, via the new
  `providers.construct_haiku()` factory. There is no RFC slug at propose
  time, so the §6.7 per-RFC funder credential path does not apply — the
  surface runs on the operator's own `ANTHROPIC_API_KEY`.
- **Degrades to silence, never error.** No key bound, an empty corpus,
  an empty draft, a rate-limited caller, or an unparseable model reply
  all yield an empty list; the propose modal hides its suggestion row,
  and the rest of the app is unaffected. So a deployment that does not
  set the key sees no change at all.
- **Privacy disclosure.** The modal carries an inline note that the
  draft text is sent to Anthropic to generate the suggestions. This is
  required for honesty (the text leaves the deployment before the RFC
  is submitted); cookie-consent does not gate it because the user has
  actively typed into a draft surface. The exact wording wants a
  counsel pass before OHM's deploy, per the #22 drafting discipline.
- **Frontend.** `ProposeModal` debounce-posts the draft (700 ms, with a
  stale-response guard); suggestion chips are clickable and add to the
  tag list (nothing auto-applies). `api.suggestTags()` is forgiving —
  any non-OK response resolves to `[]`.

Tests: 11 new (`test_tag_suggest_vertical.py`) — contributor-gating,
universe-constrained filtering, no-key / empty-corpus / rate-limit
paths, plus units for the universe gather, the tolerant reply parser,
the max cap, the empty-draft short-circuit, and provider-failure
fallback. Full suite 351 green.

Upgrade steps:

1. You **MUST** bind `ANTHROPIC_API_KEY` for the suggestion surface to
   work. On OHM this is an operator gesture — the key never touches the
   conversation. Set it from your own terminal via stdin (so the bytes
   never land in shell history):

   ```bash
   printf '%s' "$(pbpaste)" | .venv/bin/ohm-rfc-app-flotilla \
       secret set ohm-rfc-app ANTHROPIC_API_KEY
   ```

   (The §18 chat stack already reads this same key, so a deployment
   that has chat configured **MAY** already have it bound — confirm with
   `flotilla secret list ohm-rfc-app`.) If the key is absent the app
   still boots and serves normally; tag suggestions are simply
   unavailable until it is set.
2. You **MAY** tune the per-user rate limit via `TAG_SUGGEST_RATE_MAX`
   (default 30) and `TAG_SUGGEST_RATE_WINDOW_SECONDS` (default 60). The
   defaults apply when unset.
3. You **SHOULD** have counsel review the modal's disclosure wording
   before exposing the surface to users, per the #22 drafting
   discipline — the copy is honest as written, but the legal review is
   the right call for any "your text is sent to a third party" notice.

## 0.23.0 — 2026-05-28

Roadmap item #29: signing in lands the user on their most recently
viewed app state instead of the empty-state home view. Shipped from
driver session 0022.0. Per-user, server-side, on by default; first-ever
sign-ins still land on home.

1. **Server-side last-state.** A new `user_session_state` table holds one
   row per user: `last_route TEXT`, `last_route_state TEXT` (light view
   state encoded as JSON — SQLite has no native JSONB — never draft-buffer
   contents), `resume_enabled INTEGER NOT NULL DEFAULT 1`, and
   `last_updated_at`. Per-user (not per-device), matching the
   "go to where I was last" intent.
2. **Route-change capture.** A new frontend hook (`frontend/src/lib/
   useLastState.js`) debounce-posts the current route + light state to
   `PUT /api/me/last-state` for authenticated users only; anonymous
   sessions are a no-op. The handler upserts the user's row and no-ops
   when `resume_enabled` is 0.
3. **Sign-in redirect.** The stored `last_route` (+ decoded state +
   `resume_enabled`) is folded onto the existing `/api/auth/me` payload
   (no new GET endpoint), so the frontend reads it on boot. On a hard
   sign-in landing (app booted on `/`), the app redirects to the stored
   route. The redirect is gated on the #21-Part-C Amplitude `identify`
   having fired (`identifyReady`), preserving identify-then-track
   ordering — the first event on the resumed route carries the user_id.
4. **Edge cases.** Stale routes (an RFC since withdrawn or now
   unreadable) are a graceful no-op — existing routing falls through to
   the catalog/empty-state. Opt-out ships at the column level
   (`resume_enabled`); the profile-settings toggle UI is a follow-up.
   Stored state is route + light view state only, never draft contents
   (documented in `SPEC.md` §6.8).

Migration `022_user_session_state.sql` creates the table. No new
environment variables, overlay keys, or secrets.

Upgrade steps:

1. Deployments **MUST** apply database migrations; `022_user_session_state.sql`
   runs automatically on next boot (the migration runner globs
   `backend/migrations/*.sql` and applies any not yet recorded in
   `schema_migrations`). The migration is additive — a new table — and
   requires no data backfill.
2. No new environment variables, overlay keys, or secrets. No operator
   action beyond the standard `flotilla deploy ohm-rfc-app`.

## 0.22.0 — 2026-05-28

Roadmap item #26: an optional **"What will you be using this for?"**
field on both propose surfaces. Shipped from driver session 0022.0.
Additive and backward-compatible — no behavior changes for anyone who
leaves the field blank.

1. **Propose-RFC modal.** Below the required "Why is this RFC needed?"
   field (`pitch`) sits a new optional **"What will you be using this
   RFC for?"** textarea. "Needed" is the abstract justification; "using
   it for" is the concrete ground-truth use case — captured without
   being forced.
2. **Propose-PR modal.** Below the required "Why is this change needed?"
   field (`description`) sits a new optional **"What will you be using
   this change for?"** textarea.
3. **Display.** The RFC view (`RFCView` / `ProposalView`) and PR review
   view (`PRView`) render the captured use case alongside the existing
   "why" text, with a muted "left blank" treatment when absent.
4. **Persistence (deployment note).** In this deployment the framework's
   `rfcs` / PR surfaces are the Gitea-backed cache tables `cached_rfcs`
   / `cached_prs`, rebuilt by the reconciler. Because the propose write
   path is endpoint → Gitea → reconcile (and the reconciler doesn't
   carry the new field), the durable home is a canonical, reconcile-proof
   side table `proposed_use_cases` (keyed by `(scope, pr_number)`) that
   the propose/open endpoints write directly and the view endpoints read
   back. The literal nullable `proposed_use_case` columns named by the
   roadmap are also added to `cached_rfcs` / `cached_prs` for parity and
   any future reconciler that learns to carry the field. NULL/blank use
   cases simply never write a side-table row — absence is "left blank".

Migration `021_proposed_use_case.sql` adds the two cache columns
(`ALTER TABLE … ADD COLUMN proposed_use_case TEXT`), the
`proposed_use_cases` canonical table, and its lookup indexes. The
reconciler's upsert (`ON CONFLICT DO UPDATE`) sets only known columns,
so the added cache columns survive reconciles. Backend validators accept
the field as NULL/omitted (no required-validation) with an 8000-char cap
matching the existing PR `description` bound; blank/whitespace is treated
as absent.

Upgrade steps:

1. Deployments **MUST** apply database migrations; `021_proposed_use_case.sql`
   runs automatically on next boot (the migration runner globs
   `backend/migrations/*.sql` and applies any not yet recorded in
   `schema_migrations`). The migration is additive — nullable cache
   columns plus a new table — and requires no data backfill.
2. No new environment variables, overlay keys, or secrets. No operator
   action beyond the standard `flotilla deploy ohm-rfc-app`.

## 0.21.0 — 2026-05-28

UX-polish wave. Roadmap items #31 (comprehensive UX polish — foundation
slice), #24 (header "About" → "Philosophy"), #25 (inbox icon + light UX),
and #32 (session/transcript page polish). Pure frontend; no schema, no
backend changes, no new secret. Shipped from one driver session (0019.0)
via three parallel subagents working on disjoint surfaces.

1. **Design-token foundation (#31).** New `frontend/src/styles/tokens.css`
   establishes the app's first coherent design system — semantic color
   palette, type scale, spacing scale, radius scale, elevation, and a
   motion vocabulary (with `prefers-reduced-motion` honored) — as CSS
   custom properties, imported first in `main.jsx`. Before this the app
   carried ~98 distinct hardcoded hex colors, font sizes across 16
   unscaled values, and radii across 13. `App.css` and `index.css` were
   swept to the tokens (~630 color / 280 font-size / 128 radius
   references), consolidating near-duplicate grays to the nearest ramp
   step and rounding off-scale type to the nearest step. No CSS class was
   renamed or removed; an additive `:focus-visible` ring and a subtle
   hover/transition layer were added. 35 special-purpose hexes (true
   blues/violets, status dots, deep diff-contrast shades) were
   deliberately left as literals. This is the polish *foundation*; a
   follow-up (#31b) covers the bespoke per-surface re-spacing that wants
   operator review against screenshots.

2. **Header: "About" → "Philosophy" (#24).** The persistent header link
   now reads "Philosophy" (the route `/philosophy` and its page already
   existed; only the label changed).

3. **Inbox icon + light UX (#25).** The header inbox trigger's `📮`
   emoji is replaced with a dependency-free inline-SVG envelope icon
   (`aria-label="Inbox"`); no icon library was added. The inbox panel
   got a light pass — clearer unread/read distinction, mark-all-read and
   per-row affordances surfaced, better empty state, tokenized spacing in
   a new component-scoped `Inbox.css`. Behavior, filters, deep-links, and
   §15 notification data flow are unchanged. A full inbox redesign is
   deferred to a #25 follow-up (the operator's reference screenshot did
   not transmit).

4. **Session/transcript page polish (#32).** `/docs/sessions/<NNNN>` no
   longer dead-ends on a "select a transcript" placeholder: a
   single-transcript session renders that transcript inline at the
   session root; a multi-transcript session renders its `.0` driver
   transcript inline and lists the siblings. Each rendered transcript now
   carries a metadata header — session title, Started/Ended (parsed from
   the filename's ISO segments), derived Duration, an optional one-line
   TL;DR, and a "View source on git.wiggleverse.org" external link to the
   canonical raw transcript. The TL;DR reads an optional `tldr` string on
   the per-session `sessions.json` manifest entry and degrades gracefully
   when absent.

Upgrade steps:

MAY: add a `tldr` string to any per-session entry in
`wiggleverse/ohm-session-history`'s `sessions.json`
(e.g. `"0019": { "title": "…", "tldr": "one-line summary" }`) to surface
a summary in each transcript's metadata header. Absent `tldr` renders
nothing — no deployment action is required. This is a data edit in the
session-history repo, not a `flotilla` gesture.

## 0.20.0 — 2026-05-28

Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:

1. **Specs on `/docs/specs/<name>`** — a new public surface alongside the user guide that renders the framework's spec corpus at runtime. Configured via the `OHM_DOCS_SPECS` env var; the framework default carries OHM's two specs (`rfc-app/SPEC.md` and `ohm-rfc-app-flotilla/SPEC.md`) fetched from gitea raw URLs with a 5-minute TTL cache. Each spec page renders the current version only — git is the history surface; a "View source" link beside the title points at the upstream raw URL. Bare `/docs/specs` client-side redirects to the first configured spec (or renders a "no specs configured" empty state if the deployment cleared the list).

2. **Nested flyout nav hierarchy** — `/docs/*` nav now renders sessions as a tree: each session row has its transcripts nested under it as nav children, labeled by their `.N` ordinal (`0014.0`, `0014.1`, …). Each session's transcript index is fetched alongside the manifest on layout mount (Promise.all over the manifest's keys); the backend's 5-minute content TTL makes the repeat cost negligible. Always-expanded — at the current scale (≤20 sessions) lazy expansion isn't worth the click. A new "Specs" section sits between User Guide and Sessions, populated by the new manifest endpoint.

3. **`/docs/sessions/<NNNN>` body-list removed** — operator preference: navigation lives in the left nav, not in body content. The per-session page is now a session-overview card (title + transcript count + "select a transcript from the navigation" hint). Empty-state, not-found, and error paths preserved; only the inline transcript-link list is gone.

Upgrade steps:

MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS='<JSON array>'` to override the configured spec set. Each entry is `{"name": "<slug>", "title": "<human label>", "url": "<gitea raw URL>"}`. Malformed JSON, a non-array root, or an entry that fails validation (missing fields, non-slug `name`) logs a warning and falls back to the framework default; deployment startup is never crashed by a bad value.

MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS_CONTENT_TTL_SEC=300` to tune the per-spec content cache TTL.

Note on the `frontend/package-lock.json` version drift fix: the lockfile's top-level and `packages.""` version fields drifted to `0.15.0` somewhere in the v0.16.0–v0.19.0 window and weren't caught. This release syncs them to `0.20.0` alongside `frontend/package.json` and `VERSION`. No dependency changes; only the version-string fields move.

## 0.19.0 — 2026-05-28

Roadmap item #30: docs nav with on-site sessions browser. Adds a left-side flyout nav on `/docs/*` and three new public surfaces — `/docs/sessions/about` (renders the session-history README), `/docs/sessions/<NNNN>` (per-session index), `/docs/sessions/<NNNN>/<filename>` (per-transcript view). Backend mediates the fetch from `wiggleverse/ohm-session-history` over gitea raw URLs with a small in-process TTL cache (60 s manifest, 5 min content; both env-tunable). Existing `/docs` content moves to `/docs/user-guide`; bare `/docs` redirects.

Upgrade steps:

MAY: `flotilla overlay set ohm-rfc-app OHM_SESSION_HISTORY_RAW_BASE=<url>` if the deployment points at a non-OHM transcript repo. Default in code matches OHM's `wiggleverse/ohm-session-history`.

MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC=60` and `OHM_DOCS_SESSIONS_CONTENT_TTL_SEC=300` to tune cache TTLs.

Note: this release depends on the parallel restructure of `wiggleverse/ohm-session-history` into per-session `NNNN/` folders + `README.md` + `sessions.json` (driver session 0017.0, subsession 0017.2). If the repo is still flat at deploy time, `/docs/sessions/about` and the per-session pages return 404 and the route tree degrades to "About not yet published" — no JS crashes; the User Guide remains fully functional.

## 0.18.0 — 2026-05-28

**Minor — schema migration required; one env var now mandatory; no
new secrets.** This release lands the framework-side half of OHM
roadmap items #18 (Secure the SMTP relay + Gitea webhook) and #20
(Email deliverability). It is the framework counterpart to the
operator-side SMTP / DNS hardening covered in the
`EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` companion doc.

The release is shipped in five atomic slices per the v0.18.0 proposal:

1. **`build_envelope` shared helper.** A single place where every
   outbound `EmailMessage` is constructed. Lands the
   deliverability-critical headers (`Date`, `Message-ID`,
   `Auto-Submitted`) uniformly across OTC, invite, watcher
   notification, bundle, and digest paths; exposes per-kind
   unsubscribe semantics (none for OTC, mailto: for invites, full
   one-click for bulk-adjacent paths) as explicit kwargs.
2. **Migrated send paths.** `email_otc.py`, `email_invite.py`,
   `email._deliver`, `email._send_bundle`, and `digest.py` now
   build their envelopes through the helper. Per-RFC invite
   (v0.16.0) rides through `email_invite.py`'s helper and picks
   up the change for free. The shared `_SENT` test buffer also
   carries the constructed `EmailMessage` under `envelope["message"]`
   so tests can assert on the header surface directly.
3. **Webhook handler tightening.** `GITEA_WEBHOOK_SECRET` is now
   mandatory at startup — the framework refuses to load_config()
   if it's empty, unless the operator explicitly opts into the
   dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`. The
   `/api/webhooks/gitea` receiver carries defense-in-depth checks
   that surface the misconfiguration loudly at the request layer
   too. Mis-targeted webhooks (a hook on a fork or a stale Gitea
   binding) now log at INFO instead of silently 200-OK'ing.
4. **`outbound_emails` audit table + admin endpoint.** Every send
   helper writes one row to `outbound_emails` before returning,
   capturing the send attempt regardless of outcome
   (status='sent' / 'failed' / 'deferred'). The new admin endpoint
   `GET /api/admin/outbound-emails` (filterable by kind / status /
   to_address) lets the operator answer "did this person ever get
   their invite?" without grepping VM logs. No admin UI ships
   with v0.18.0; operator queries via curl + jq for now.
5. **Bounce correlation.** The `POST /api/webhooks/email-bounce`
   body accepts a new optional `message_id` field; when supplied,
   the handler stamps status='bounced' on the matching
   `outbound_emails` row and returns the row id as
   `correlated_id`. The pre-existing hard-bounce ->
   `email_opt_out_all = 1` flow still fires.

The `POST /api/email/unsubscribe` endpoint also lands in Slice 2
as the matching receiver for the new `List-Unsubscribe-Post:
List-Unsubscribe=One-Click` header (Gmail and Yahoo POST that
payload on the user's one-click action per RFC 8058 — the GET
endpoint alone is no longer sufficient for senders at OHM's tier).

### Added

- **`backend/app/email_envelope.py`** — the `build_envelope` helper.
  Single source of truth for every outbound `EmailMessage`'s
  headers + body shape. Standalone module so tests can exercise it
  without booting the FastAPI app.
- **`backend/migrations/020_outbound_emails.sql`** — the audit
  table. Single new table with three indexes (to_address, sent_at,
  message_id); no changes to existing tables.
- **`email.record_outbound(...)`** — best-effort write helper every
  send path calls. Catches `RuntimeError` (so pure-helper unit
  tests where `db.init()` was never called don't break) and any
  other exception (so the audit write never breaks a send).
- **`GET /api/admin/outbound-emails`** in `api_admin.py` —
  admin-only listing of `outbound_emails`. Newest-first, filterable
  by kind, status, and to_address (case-insensitive). Returns
  `{items: [...], has_more}` per the rest of the admin endpoints'
  shape.
- **`POST /api/email/unsubscribe`** in `api_notifications.py` —
  RFC 8058 one-click receiver. Accepts the same `?t=` token as the
  GET handler; idempotent; returns 200 + `{ok, category}` on
  success.
- **`all` synthetic category** for unsubscribe URLs. Used by the
  bundle + digest paths (which can't honor per-category opt-outs
  because they span multiple categories); flips
  `email_opt_out_all = 1` rather than a per-category column.

### Changed

- **`backend/app/email.py`** — `EmailConfig` gains
  `unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`, default
  falls back to `EMAIL_FROM`). `_deliver` and `_send_bundle`
  build envelopes through `build_envelope` and thread `kind` +
  `notification_id` into the audit write.
- **`backend/app/email_otc.py`** + **`email_invite.py`** — both
  call `build_envelope` and `record_outbound`. OTC carries no
  `List-Unsubscribe` (recipient explicitly requested the code);
  invite carries `List-Unsubscribe: <mailto:…>` only (no signed
  URL — the invitee isn't a user yet, no per-user opt-out row
  exists).
- **`backend/app/digest.py`** — calls `_deliver` with `kind='digest'`
  + the new `all`-category one-click unsubscribe.
- **`backend/app/webhooks.py`** — refuses 500 at request time if
  the secret is empty + bypass isn't set; logs a loud warning
  per-request when running under the bypass; logs INFO when a
  hook targets a repo not in `cached_rfcs`.
- **`backend/app/config.py`** — `load_config()` raises
  RuntimeError if `GITEA_WEBHOOK_SECRET` is empty unless
  `RFC_APP_INSECURE_WEBHOOKS=1`.
- **`backend/app/api_notifications.py`** — GET unsubscribe handler
  accepts the `all` category (sets `email_opt_out_all = 1`).
  Bounce webhook body adds optional `message_id` field; response
  shape adds `correlated_id` field. (Tests that read the exact
  response shape — currently just
  `test_bounce_webhook_refuses_unsigned_when_secret_configured`
  in test_e2e_smoke.py — updated to assert on the new shape.)
- **`backend/tests/test_propose_vertical.py`** — the shared
  `tmp_env` fixture binds a fake `GITEA_WEBHOOK_SECRET` so all
  252 pre-v0.18.0 tests boot cleanly under the new mandatory
  secret. Tests that want to exercise the dev-bypass path
  monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1` explicitly.

### Tests

- 15 new unit tests in `test_email_envelope.py` for the helper.
- 10 new integration tests across `test_otc_vertical`,
  `test_admin_create_user_invite_vertical`, and
  `test_notifications_vertical` covering: OTC has no
  List-Unsubscribe; invite has mailto: only; notification has
  full one-click; POST one-click flips per-category; `all` flips
  global; respects `EMAIL_UNSUBSCRIBE_MAILTO` override.
- 7 new integration tests in `test_webhooks_vertical.py` covering
  the startup-time mandatory-secret check, the dev-bypass, and
  the request-time signature verification including the unknown-
  repo log line.
- 11 new integration tests in `test_outbound_emails_vertical.py`
  covering the audit table write path (OTC / invite / notification),
  the admin endpoint (list, filter by kind, filter by to_address,
  non-admin refusal), and the bounce correlation (matched
  message_id stamps status='bounced'; unknown message_id is
  logged; absent message_id falls back to legacy behavior; bounced
  rows surface in admin endpoint with `?status=bounced`).
- One test updated for intentional response-shape change:
  `test_e2e_smoke.test_bounce_webhook_refuses_unsigned_when_secret_configured`.

Full suite: 295 passed (was 252 pre-v0.18.0).

### Migration

- **`backend/migrations/020_outbound_emails.sql`** — auto-applied
  on next backend start. Single new table with three indexes; no
  changes to existing tables.

### Upgrade steps (from 0.17.0)

- Operators **MUST** ensure `GITEA_WEBHOOK_SECRET` is set in the
  deployment's env. The framework now refuses to start if it's
  empty. (For OHM-flotilla deployments,
  `flotilla secret list <deployment>` confirms the binding; OHM
  has carried this binding since v0.14.0, so the upgrade is
  gesture-free for OHM specifically.)
- Operators **MAY** set `RFC_APP_INSECURE_WEBHOOKS=1` to bypass
  the requirement in local-dev environments. Production
  deployments **MUST NOT** set this; if they do, every webhook
  POST logs a loud warning line per request.
- Operators **MAY** set `EMAIL_UNSUBSCRIBE_MAILTO` to route
  `List-Unsubscribe: <mailto:…>` opt-out courtesy mail to a
  humans-monitored mailbox distinct from the no-reply
  `EMAIL_FROM` sender. Default falls back to `EMAIL_FROM`.
- You **MUST** apply schema migration
  `020_outbound_emails.sql`. The migration creates a single new
  table with three indexes; the framework runs migrations
  automatically at process start, so no manual step is required
  beyond restarting the backend so the migration runner picks
  the file up. Existing deployments pick it up on first start
  after upgrade with no operator action required.
- You **MUST** rebuild the frontend and restart the backend
  after upgrading. `frontend/package.json#version` and `VERSION`
  both move to `0.18.0`. No new secrets (the `outbound_emails`
  table writes synchronously to the same SQLite file as every
  other write).
- Operators **SHOULD** run a `mail-tester.com` probe against the
  upgraded deployment to confirm the new envelope headers
  (`Date`, `Message-ID`, `Auto-Submitted`, `List-Unsubscribe`,
  `List-Unsubscribe-Post`) land cleanly with the upstream SMTP
  relay's DKIM signing. The expected delta from pre-v0.18.0 is
  +2-3 points on the spam-score axis (typical 5-6/10 baseline
  → 9+/10 post-upgrade).


## 0.17.0 — 2026-05-28

**Minor — schema migration required; no new env vars; no new secrets.**
This release lands admin-create user with role assignment + invite
email (roadmap item #16, §6.1). From the v0.9.0 `/admin/users` surface,
an admin can now type first name, last name, email, role, and an
optional custom message; the framework provisions the `users` row with
the chosen role and `permission_state='granted'` (the admin's hand is
the grant) and sends an invite email carrying a single-use claim link.
The invitee clicks through to `/invites/claim?token=…`, the token is
verified and consumed, the session is established, and the user is
routed to the passcode-set screen (per v0.10.0) on first sign-in.

Distinct from #12 (which ships in parallel in this wave): #12 is
per-RFC contribution/discussion membership and uses
`rfc_invitations` (slot 018). v0.17.0 is platform-level access
provisioning by an admin and uses `user_invite_tokens` (slot 019).
Both can coexist; both surface in the same SMTP relay but with
distinct email templates.

Design decisions documented inline (see `backend/app/invites.py`'s
module docstring + the migration's header comment):

  * **Token shape: opaque DB token, not JWT.** 256 bits of CSPRNG
    entropy (`secrets.token_urlsafe(32)`), bcrypt-hashed at rest.
    Opaque chosen over JWT because revocation is then a single SQL
    UPDATE — admin-issued invites are exactly the kind of thing an
    admin should be able to yank back without rotating a signing
    key. The raw token only ever lives in the outbound email link
    and the inbound claim body.
  * **TTL: 7 days, hard-coded constant** (`INVITE_TOKEN_TTL_DAYS`
    in `backend/app/invites.py`). Env-var configurability is a
    §19.2 candidate; the constant is exposed as a single point of
    edit if a deployment wants to override.
  * **Immediate send, no admin-review-then-send queue.** Matches
    how the v0.9.0 beta-request admin notification works (single
    SMTP path). Admin-preview-before-send is a future enhancement.
  * **Bulk-invite (CSV paste) deferred.** v0.17.0 is one-at-a-time;
    a follow-up release can layer bulk on top of the same
    `POST /api/admin/users` body shape with minimal disruption.
  * **OTC skipped on first sign-in.** Per the roadmap: clicking the
    unique token in the email is itself proof of email control, so
    the claim flow signs the invitee in directly. Subsequent
    sign-ins go through the standard OTC / passcode paths.
  * **No `users` table changes.** The brief floated
    `first_sign_in_at` / `last_seen_at IS NULL` as the
    "(pending invite)" discriminator, but the existing
    `users.last_seen_at` column is NOT NULL with a `datetime('now')`
    default (migrations/001) and no `first_sign_in_at` column
    exists. Rather than land a schema migration to introduce one,
    the discriminator is the existence of an active (not-claimed,
    not-expired) row in `user_invite_tokens` joined on
    `invited_user_id`. The admin user-listing carries a
    `pending_invite` field populated via that join; on claim, the
    badge clears naturally as the invite row's `claimed_at`
    populates.
  * **Admin-create vs. self-flip refusals.** Self-invite is refused
    422 (use the role-change channel for self-edits). Duplicate
    email is refused 409 (use the existing role / grant gestures on
    the existing user). Owner-grant by a non-owner admin is refused
    422 (§6.1's owner-zero is the only owner bootstrap path; the
    sitting owner must issue the invite).

### Added

- **`POST /api/admin/users`** (`backend/app/api_admin.py`) — admin-only.
  Body: `{ email, first_name?, last_name?, role, custom_message? }`.
  Provisions the `users` row with the chosen role and writes the
  `user_invite_tokens` row + dispatches the invite email + writes a
  `permission_events` row with `event_kind='user_invited'`. Returns
  `{ ok, invite_id, invited_user_id, email, role }` on success;
  surfaces the four refusals (403/422/409/422) per their distinct
  paths.
- **`GET  /api/admin/users/invites`** — admin-only. Lists active
  (not claimed, not expired) invites with the admin who created them
  joined through for display. Powers the "I sent these but they
  haven't been claimed yet" admin view.
- **`POST /api/invites/claim`** (`backend/app/main.py` — alongside
  `/auth/otc/verify` and `/auth/device-trust/start` since it shares
  the device-trust cookie helpers). Anonymous-reachable. Body:
  `{ token, trust_device? }`. Validates the token, consumes the
  invite row, signs the user in, optionally mints a device-trust
  cookie, and returns `{ ok, user, needs_passcode }`. The
  `needs_passcode` hint drives the frontend's route-to-passcode-set
  vs. route-to-home decision. Maps token-failure modes to distinct
  HTTP statuses: expired/claimed → 410, unknown/invalid → 400.
- **`backend/app/invites.py`** — the create + claim + list module.
  Mirrors the `device_trust.py` shape: opaque-token issuance with
  bcrypt-at-rest, candidate-set walk on lookup, dataclass-bracketed
  outcomes (`CreateOutcome` / `ClaimOutcome` / `PendingInviteRow`).
  Carries the `INVITE_TOKEN_TTL_DAYS = 7` constant and the
  `CUSTOM_MESSAGE_MAX_LENGTH = 500` mirror of the API-side bound.
- **`backend/app/email_invite.py`** — sibling of `email_otc.py`. Reuses
  `EmailConfig.from_env()` for the SMTP plumbing + From identity;
  composes a separate template (subject "You're invited to <app> by
  <admin>"; body names the inviter, embeds the optional custom
  message in a clearly-delimited indented block if present, and
  carries the claim URL). Dev / no-SMTP path logs the envelope to
  the shared `_SENT` buffer so backend tests can assert on the
  outbound shape.
- **Schema migration `019_user_invite_tokens.sql`** — new
  `user_invite_tokens` table (id, email, role, first_name, last_name,
  custom_message, token_hash, expires_at, created_at,
  created_by_admin_id, claimed_at, claimed_by_user_id,
  invited_user_id). Three indexes: unique on `token_hash`
  (documents the no-collision invariant); `(email, claimed_at)` for
  the "is this email already invited?" pre-check; and
  `(created_by_admin_id, created_at DESC)` for the per-admin
  invites listing. Slot 018 is reserved for the parallel #12
  release shipping in the same wave; slot 016 stays
  reserved-and-skipped per Session K's v0.9.0 integration.
- **`frontend/src/components/InviteClaim.jsx`** — the
  `/invites/claim?token=…` landing page. Reads the token from the
  URL, renders a "Claim my account" CTA with an optional
  "trust this device for 30 days" checkbox, calls
  `POST /api/invites/claim` on submit, and routes onward
  (`/settings/notifications#sign-in` if `needs_passcode`, else `/`)
  on success. Anonymous-reachable.
- **"Create user + invite" affordance** on `/admin/users`
  (`frontend/src/components/Admin.jsx`). A header button opens a
  modal with email / first / last / role / custom-message inputs
  (the textarea shows a "chars left" counter against the 500-char
  ceiling). On submit, the modal calls
  `POST /api/admin/users` and refreshes the user listing.
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
  inline with v0.17.0): `USER_INVITED` event fires from
  `CreateUserInviteModal` on successful invite-send with
  `{ target_user_id, initial_role, custom_message_chars }` — the
  OHM `invited_user_id` returned by `POST /api/admin/users`
  becomes the dashboard's binding for the future Amplitude user
  record; `custom_message_chars` is a coarse signal of admin
  effort (0 = template-only, 1+ = personalized) and carries no
  PII. `INVITE_CLAIMED` event fires from `InviteClaim.jsx` on
  successful claim with `{ invited_by_admin_id, initial_role,
  needs_passcode, trust_device }` — BUT the claim handler first
  calls `identify({ user_id, properties: { claim_method:
  'admin-invite', invited_at (setOnce), invited_by_admin_id
  (setOnce), initial_role (setOnce) } })` so the Amplitude user
  record is created with the OHM user_id from the very first
  event the invitee fires, never as an anonymous device that
  retroactively links. setOnce semantics preserve the original
  invite context even if the invitee later changes roles.
- **"(pending invite)" badge** inline on the user-listing's
  per-row handle (rendered when the row's `pending_invite` field
  is populated by the backend's join through `user_invite_tokens`).
  Clears automatically on claim as the invite row's `claimed_at`
  populates.

### Changed

- **`backend/app/api_admin.py`** — imports `invites` + `email_invite`
  + `EmailConfig`; adds the two new endpoints alongside the existing
  `set_role` / `set_permission` neighbors; extends `list_users` to
  join through `user_invite_tokens` and emit the `pending_invite`
  field on each row. The new `CreateUserInviteBody` pydantic model
  carries the body bounds (320-char email, 120-char first/last,
  regex-pinned role, 500-char custom_message) so malformed input
  fails at the body bound (422) instead of at the SQL layer.
- **`backend/app/main.py`** — imports `invites as invites_mod`;
  adds the `InviteClaimBody` pydantic model alongside
  `PasscodeVerifyBody`; mounts the `POST /api/invites/claim`
  endpoint in the OAuth router so it can reuse the
  `_set_device_trust_cookie` helper.
- **`frontend/src/api.js`** — exports `createUserInvite()`,
  `listUserInvites()`, `claimInvite()`. Same fetch shape as the
  rest of the v0.9.0 / v0.10.0 admin neighborhood.
- **`frontend/src/App.jsx`** — imports `InviteClaim`; registers the
  `/invites/claim` route alongside `/beta-pending` (both are
  anonymous-reachable auth-shape landings).

### Migration

- **`backend/migrations/019_user_invite_tokens.sql`** — auto-applied
  on next backend start. Single new table with three indexes; no
  changes to existing tables.

### Upgrade steps (from 0.14.0)

- You **MUST** apply schema migration `019_user_invite_tokens.sql`.
  The migration creates a single new table with three indexes; the
  framework runs migrations automatically at process start, so no
  manual step is required beyond restarting the backend so the
  migration runner picks the file up.
- You **MUST** rebuild the frontend and restart the backend after
  upgrading. `frontend/package.json#version` and `VERSION` both
  move to `0.17.0`. No new env vars; no new secrets (the invite
  email rides the existing SMTP relay configured for v0.7.0's OTC
  mail).
- You **MAY** announce the new admin-create gesture to existing
  admins. Existing user rows are unaffected — the
  `user_invite_tokens` table is empty post-migration, and the
  user-listing's new `pending_invite` field is null on every
  existing row. The bootstrap shape for the very first admin
  account stays the v0.9.0 path (DB-level role flip on an OTC-
  provisioned row); the v0.17.0 admin-create gesture works
  end-to-end once at least one admin exists.


## 0.16.0 — 2026-05-28

**Minor — schema migration auto-applied; no operator action.** This
release lands the owner-only invite for per-RFC PR or PR-less
discussion (roadmap item #12). The RFC's owner can now invite
specific users by email to one of two per-RFC roles —
`contributor` (open PRs against the RFC AND join its discussion) or
`discussant` (join the discussion only). Non-invited users keep
the v0.6.0 anonymous-read contract: they can read but cannot
write/discuss that RFC. Invitations are token-encoded in a
transactional email; acceptance lands a per-RFC collaborator row
and surfaces in the admin user-management page (additive on the
existing `/api/admin/users` shape) so the platform-grant decision
has the per-RFC context to inform it. The platform-level grant
remains the admin's call — this release adds a per-RFC membership
layer beneath it, not a new platform-grant path.

The per-RFC write gate is layered on top of the existing
`require_contributor` (v0.8.0) gate, not in place of it: a user
must be platform-granted AND hold an accepted per-RFC role (or
be the RFC owner / a platform admin/owner) to write. A super-
draft with no frontmatter owners yet (pre-§13.1 claim) falls
through to the platform-granted contract — there's no owner to
issue invitations, so the gate is open until one exists. This
preserves the v0.6.0 / v0.7.0 / v0.8.0 contracts inside their
domains and confines item #12's change to "an RFC has owners →
those owners decide who writes."

### Added

- **`backend/migrations/018_rfc_invitations.sql`** — two tables.
  `rfc_invitations` carries the lifecycle row (issued, accepted,
  revoked, expired) with the opaque token the email link encodes,
  the inviter, the invitee email, the role-in-RFC, and the 30-day
  expiry. `rfc_collaborators` is the accepted-invitation
  substrate — the compact (rfc, user, role) shape the write gate
  consults. Both tables are FK-cascaded against `cached_rfcs` and
  `users` per §5's cascade rules. Indexed for the owner's listing,
  the accept-by-token lookup, and the per-user read.
- **`backend/app/api_invitations.py`** — the §17 surface. Five
  endpoints: `POST /api/rfcs/{slug}/invitations` (create + email),
  `GET /api/rfcs/{slug}/invitations` (owner's listing),
  `POST /api/rfcs/{slug}/invitations/{id}/revoke`,
  `GET /api/invitations/accept?token=…` (preview), and
  `POST /api/invitations/accept` (redeem). The email reuses
  `EmailConfig.from_env()` and the `_SENT` buffer the OTC and
  notification mailers share — transactional, no preferences
  honored, no unsubscribe footer. A failure to send does NOT
  roll back the row; the owner has the token on the listing
  surface for an out-of-band share.
- **`backend/app/auth.py`** — four helpers. `is_rfc_owner`
  reads the frontmatter `owners_json`. `is_rfc_collaborator`
  reads the v0.16.0 `rfc_collaborators` table. `can_discuss_rfc`
  and `can_contribute_to_rfc` are the composite predicates the
  write endpoints consult (platform admin/owner OR no-owners-yet
  fall-through OR RFC owner OR per-RFC collaborator at the right
  role). `can_invite_to_rfc` is the issue-side predicate (RFC
  owner or platform admin/owner only — collaborators don't get
  invite power).
- **`frontend/src/components/InvitationsModal.jsx`** — the RFC
  owner's surface: an email input + role picker for sending,
  and a status table for listing/revoking. Visible only to the
  RFC's owner or a platform admin/owner (the backend gates the
  endpoints regardless).
- **`frontend/src/components/AcceptInvitation.jsx`** — the
  `/invitations/accept?token=…` landing page. Previews what the
  invitation grants, refuses on email mismatch / revoked /
  expired with a single sentence each, redirects to the RFC's
  view on accept.
- **API client (`frontend/src/api.js`)** — five new helpers:
  `listRFCInvitations`, `createRFCInvitation`,
  `revokeRFCInvitation`, `previewInvitation`, `acceptInvitation`.
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
  inline with v0.16.0): `INVITATION_SENT` event fires from
  `InvitationsModal.jsx` on successful send with `{ rfc_slug,
  role_in_rfc }`; `INVITATION_ACCEPTED` event fires from
  `AcceptInvitation.jsx` on successful accept with the same shape —
  but the accept path first calls `identify({ user_id, properties:
  { invited_at (setOnce), last_invited_to_rfc,
  last_invite_role_in_rfc, claim_method: 'rfc-invite' } })` so the
  Amplitude user record carries the invite context from the moment
  of acceptance. No invitee email or other PII enters the event
  body — only the slug, role, and the inviter's identity (through
  the standard signed-in identify on the inviter's session).

### Changed

- **`backend/app/api.py`** — registers
  `api_invitations.make_router()` alongside the existing routers.
- **`backend/app/api_discussion.py`** — `POST .../discussion/threads`
  and `POST .../discussion/threads/{thread_id}/messages` now compose
  the new `auth.can_discuss_rfc` predicate after the existing
  `require_contributor` check. A platform-granted user without a
  per-RFC discussion role on an RFC with owners gets 403 with
  "This RFC's owner has not invited you to its discussion."
- **`backend/app/api_branches.py`** — `POST .../promote-to-branch`
  and `POST .../start-edit-branch` now compose
  `auth.can_contribute_to_rfc`. Same shape: platform-granted but
  uninvited → 403.
- **`backend/app/api_prs.py`** — `POST .../open-pr` also composes
  `auth.can_contribute_to_rfc` so a user whose per-RFC role was
  revoked between branch-cut and PR-open is refused at the
  ship line.
- **`backend/app/api_admin.py`** — `GET /api/admin/users` carries
  a new `rfc_invitations` array per user (empty if none), naming
  each accepted per-RFC collaboration with the RFC slug/title,
  the role, the inviter, and the timestamp. Additive — the
  existing v0.9.0 columns are unchanged; consumers that don't
  read the new field see the legacy shape.
- **`frontend/src/App.jsx`** — registers the
  `/invitations/accept` route (visible to anonymous + signed-in
  viewers; signed-out viewers see a sign-in prompt).
- **`frontend/src/components/RFCView.jsx`** — additive
  "Invitations" button in the RFC header strip, visible to RFC
  owners and platform admins/owners on both super-drafts and
  active RFCs. Mounts the new modal on click.
- **`backend/tests/test_propose_vertical.py`** — adds the
  `grant_rfc_collaborator` test helper so v0.5.0/v0.6.0/v0.8.0-era
  tests that exercise non-owner contribution can opt into the new
  invitation contract without rewriting their setup.
- **`backend/tests/test_pr_flow_vertical.py`,
  `backend/tests/test_graduation_vertical.py`,
  `backend/tests/test_e2e_smoke.py`** — three tests that signed in
  as non-owner contributors now seed an accepted per-RFC
  collaborator row first (mirroring the production invite→accept
  dance). The test intent is unchanged; the precondition is now
  explicit.

### Migration

- **`018_rfc_invitations.sql`** — auto-applied on backend start by
  the existing `db.run_migrations()` sweep. The two new tables
  are empty at upgrade time; no existing data is touched. No
  operator gesture needed.

### Upgrade steps (from 0.15.0, or 0.14.0 if 0.15.0 is skipped)

- You **MUST** rebuild the frontend and restart the backend after
  upgrading so the new endpoints, the migration, the gate
  composition in `api_discussion`/`api_branches`/`api_prs`, and the
  new frontend routes/components are picked up. `frontend/package.json#version`
  and `VERSION` both move to `0.16.0`.
- You **MUST NOT** set any new env var — there are no new secrets
  and no new overlay keys. The email path reuses the existing
  `SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` /
  `EMAIL_FROM` / `EMAIL_FROM_NAME` / `APP_URL` / `EMAIL_ENABLED`
  variables that the v0.7.0 OTC and v0.5.0 notification paths
  already require. Deployments that have those wired need no
  configuration change.
- You **MUST NOT** apply the migration manually — the backend's
  migration runner picks up `018_rfc_invitations.sql` on next
  start. (If you've configured an external migration tool, run it
  before starting the backend; the framework's own runner is
  idempotent against already-applied migrations.)
- You **SHOULD** inform existing RFC owners that they can now
  invite collaborators from the RFC view's header strip. RFCs
  with frontmatter owners that pre-date this release see no
  behavioral change for the owner; the change is visible to
  non-owner contributors who previously could write on any RFC
  and now must be invited first.
- You **MAY** seed `rfc_collaborators` rows directly via SQL for
  pre-existing per-RFC working relationships you want to
  grandfather past the v0.16.0 cutover. The `invitation_id`
  column is nullable for exactly this purpose. Production
  deployments without that history can ignore this option.
## 0.15.0 — 2026-05-28

**Minor — no schema migration; one new build-time env var bound via
`flotilla overlay set`.** This release ships Amplitude Analytics +
Session Replay instrumentation (roadmap item #13). The frontend
gains a small wrapper around `@amplitude/unified` that gates SDK
initialization on the v0.13.0 cookie/privacy consent — the SDK is
never loaded for visitors who have not granted analytics consent,
no session is recorded, no network request fires; a later consent
flip to `denied` calls `setOptOut(true)` so events and session
replay stop immediately. The wrapper exposes a stable taxonomy of
nine events (Page Viewed, RFC Viewed, User Signed In / Signed Out,
RFC Proposed, PR Opened, Comment Posted, Beta Access Requested, Admin
Permission Decision) wired into the existing routes, the Login flow,
the propose / open-PR / discussion / PR-review surfaces, and the
admin grant/revoke action. Event bodies carry only ids and enums; no
free-text fields (titles, comment bodies, names, emails) are ever
sent. **Session replay** records sessions at `sampleRate: 1` (100%)
— vendor-recommended default; gated by the same v0.13.0 analytics
consent. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY`
at build time; when unset, the wrapper logs one console warning and
no-ops so dev environments without analytics keep working. No backend
events ship in this release — Amplitude SaaS holds the events,
nothing lands in our DB, no migration.

### Added

- **Analytics wrapper** (`frontend/src/lib/analytics.js`). Public
  surface: `track(name, props)`, `identify({ user_id, properties? })`,
  `setUserProperties(properties)`, `anonymize()`, the `EVENTS`
  taxonomy constant, and a `__resetForTests` helper. Internally
  lazy-imports `@amplitude/unified` and calls
  `amplitude.initAll(API_KEY, { analytics: { autocapture: true },
  sessionReplay: { sampleRate: 1 } })` only after consent is
  granted; queues pre-init calls and drains them on init resolve;
  flips `setOptOut(true)` on a granted→denied consent change (stops
  both analytics events and session replay). The wrapper subscribes
  to `onConsentChange()` so a freshly-banner-clicked "analytics on"
  flips the SDK live without a page reload.
- **User identity lifecycle** (per `ohm-rfc/ROADMAP.md` #21 Part C —
  shipped inline with v0.15.0 instead of waiting for a follow-up).
  `identify({ user_id, properties })` accepts a property bag that
  applies as an Amplitude `Identify` event with `.set()` semantics
  by default; values wrapped as `['__setOnce__', value]` apply with
  `.setOnce()` semantics (immutable after first write — for
  account-history markers like `first_sign_in_at`). The new
  `setUserProperties(properties)` exposes the same property-apply
  path for mid-session state changes (role grant/revoke, passcode
  set, device trusted) so the Amplitude record stays current without
  waiting for the next sign-in. `anonymize()` now clears both the
  user_id binding AND the pending-property cache so a subsequent
  sign-in as a different user starts with a fully fresh slate.
- **Event taxonomy** wired into the app:
  - `Page Viewed` — fires from `App.jsx` on every route change with
    `path` (`location.pathname`); the location hook owns the firing
    and dedupes by path.
  - `RFC Viewed` — fires from `RFCView.jsx` once per slug load with
    `rfc_slug` and `rfc_id`.
  - `User Signed In` — fires from `Login.jsx` with
    `method ∈ { 'otc', 'passcode', 'trust-device' }` matching the
    three sign-in paths from v0.7.0 / v0.10.0 / v0.11.0.
  - `User Signed Out` — fires from `App.jsx`'s "Sign out" click,
    followed by `anonymize()` to clear the SDK's user binding before
    the hard nav to `/auth/logout`.
  - `RFC Proposed` — fires from `ProposeModal.jsx` on submit success
    with `rfc_slug`.
  - `PR Opened` — fires from `PRModal.jsx` on submit success with
    `rfc_slug` and `pr_number`.
  - `Comment Posted` — fires from `RFCDiscussionPanel.jsx`
    (`surface: 'discussion'`) and from `PRView.jsx`
    (`surface: 'pr'`, with `pr_number`) on each post-success.
  - `Beta Access Requested` — fires from `Login.jsx` capture-profile
    submit success. No PII in the event.
  - `Admin Permission Decision` — fires from `Admin.jsx`'s grant /
    revoke action with `action ∈ { 'grant', 'revoke' }` and
    `target_user_id` (string).
- **User binding + properties** (`App.jsx`): when `me.authenticated`
  lands and a user id is available, the wrapper's
  `identify({ user_id, properties })` is called with
  `String(viewer.id)` AND a durable property bag — `role`,
  `permission_state`, `passcode_set`, `device_trusted` (mutable;
  refresh each sign-in), plus `first_sign_in_at` and
  `account_created_at` (setOnce — immutable user-history markers).
  The sign-out gesture calls `anonymize()` before the nav. No email,
  display name, gitea_login, or other PII is passed through the SDK —
  Amplitude only sees opaque ids, enums, timestamps, booleans.
- **`@amplitude/unified`** dependency added to
  `frontend/package.json` (analytics + session replay in one
  install). Lockfile updated.
- **`VITE_AMPLITUDE_API_KEY`** documented in `frontend/.env.example`
  with the secret-vs-overlay binding caveat (see below).

### Changed

- **`frontend/src/App.jsx`** — adds `useLocation` for the route-change
  Page Viewed firing, a `lastUserIdRef` memo to call
  `identify` once per signed-in viewer, and an `onClick` handler on
  the "Sign out" link that fires `User Signed Out` + `anonymize()`
  before the hard nav.
- **`frontend/src/components/Login.jsx`** — fires `User Signed In`
  with the appropriate `method` at each of the three sign-in points
  (trust-device cookie path, passcode verify success, OTC verify
  success), and fires `Beta Access Requested` on capture-profile
  submit success.
- **`frontend/src/components/ProposeModal.jsx`** — fires `RFC Proposed`
  with `rfc_slug` on submit success.
- **`frontend/src/components/RFCView.jsx`** — fires `RFC Viewed`
  inside the `getRFC` resolution so the event is keyed on the slug
  param and includes the loaded `rfc_id`.
- **`frontend/src/components/PRModal.jsx`** — fires `PR Opened` with
  `rfc_slug` and `pr_number` on submit success.
- **`frontend/src/components/RFCDiscussionPanel.jsx`** — fires
  `Comment Posted` with `surface: 'discussion'` on send-success.
- **`frontend/src/components/PRView.jsx`** — fires `Comment Posted`
  with `surface: 'pr'` and `pr_number` on review-comment success.
- **`frontend/src/components/Admin.jsx`** — fires
  `Admin Permission Decision` on grant/revoke success.

### Migration

- **No schema migration.** Amplitude SaaS holds the events; the
  framework's DB is unchanged. Migration slot **015** is unused by
  this release and remains available for the next minor that needs a
  schema bump.

### Caveat — overlay binding for `VITE_AMPLITUDE_API_KEY`

Amplitude browser API keys are embedded in the frontend bundle at
build time and visible to anyone with browser dev tools. They are
public by design — same nature as the v0.12.0
`VITE_TURNSTILE_SITE_KEY` (also public, also bundle-embedded,
explicitly contrasted with `CLOUDFLARE_TURNSTILE_SECRET` which is
the real secret-half of that pair). The Amplitude installation
guidance from the vendor shows the key inline as a literal string
argument to `initAll(…)`, confirming the public framing. This
release accordingly binds the value via `flotilla overlay set`,
not `flotilla secret set` — the env-var name is `VITE_AMPLITUDE_API_KEY`
(Vite-prefix convention, so the build picks it up directly without
an alias step).

(Roadmap row #13 originally said "new secret: AMPLITUDE_API_KEY";
that wording predated vendor consultation. Mid-Session-L the
operator provisioned the Amplitude project, surfaced the vendor's
recommended init prompt, and the binding settled as overlay. The
roadmap row will be updated to match when #13 ships.)

### Caveat — session replay scope and consent

This release enables Amplitude Session Replay at `sampleRate: 1`
(100% of sessions recorded for full-DOM playback). The vendor's
installation wizard recommends this default for new deployments —
maximum learning during the early phase. The v0.13.0 single
"analytics" consent toggle gates session replay together with
events, so no recording happens without explicit opt-in. A future
release **MAY** split this into a separate consent category for
session replay specifically (recording has a meaningfully larger
privacy footprint than event counters); §19.2 candidate.

### Upgrade steps (from 0.14.0)

- You **MUST** install the new frontend dependency before building:
  `cd frontend && npm install` picks up `@amplitude/unified` from
  the updated `frontend/package.json` and the refreshed
  `package-lock.json`. The lockfile change is committed.
- You **MUST** rebuild the frontend after upgrading so the analytics
  wrapper and its consent gate ship to viewers. `frontend/package.json#version`
  and `VERSION` both move to `0.15.0`. No schema migration; the
  backend is unchanged for this release.
- **MUST**: before deploying, the operator runs `/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/bin/ohm-rfc-app-flotilla overlay set ohm-rfc-app VITE_AMPLITUDE_API_KEY=<key>` to bind the Amplitude project's public API key. (Receiving the value in the conversation is fine — it's bundle-embedded by design, same as `VITE_TURNSTILE_SITE_KEY`.) The deploy **SHOULD NOT** proceed before this binding exists; if the binding is absent, the frontend's analytics wrapper no-ops with a console warning and the rest of the app continues to function — but no events or session replays are sent.
- You **MAY** leave `VITE_AMPLITUDE_API_KEY` unset in dev environments
  — the wrapper detects the empty value and no-ops with a single
  console warning. The app, the consent banner, and every other
  surface keep working unchanged.
- You **SHOULD** verify after deploy that the Amplitude dashboard
  receives events and a session replay when a consenting browser
  exercises one of the taxonomy events (the easiest probe: open the
  deployed site in an Incognito window, accept analytics on the
  consent banner, navigate to an RFC, and watch the project's live
  event stream + replay panel).

## 0.14.0 — 2026-05-28

**Minor — no operator action required; new optional env var.** This
release ships `DOCS.md` and the `/docs` route — a public-facing user
guide that translates `SPEC.md` into plain prose for readers,
proposers, and contributors. The originating need was the
admin-vs-owner distinction on the `/admin/users` surface (the §6.1
role separation was load-bearing but only documented in spec voice);
the response was a single guide that covers the framework's user-
facing surfaces end-to-end. Mirrors `/philosophy` end-to-end: a
markdown file checked into the repo root, served by a sibling backend
loader, rendered with `MarkdownPreview`. No schema migration. No
required env-var changes. The new "Docs" header link sits alongside
the persistent "About" link from §14.3 and is reachable by anonymous
viewers per the same v0.3.0 anonymous-read contract.

### Added

- **`DOCS.md`** at the repo root — the user-facing guide. Covers
  reading anonymously, signing in, proposing an RFC, super-drafts vs
  active RFCs, the discussion-vs-contribution distinction (§10.10),
  working on a branch (contribute mode, AI proposals, manual edits,
  flags, branch visibility, contribute grants, hygiene), opening and
  reviewing PRs, graduation (§13), withdrawal and reopening, the AI
  participant (§6.6 / §6.7 / §18), notifications and watch states
  (§15), and the full roles-and-permissions story (§6 in plain
  prose: anonymous / contributor / admin / owner, per-RFC
  owners + arbiters, per-branch contribute grants, the write-mute,
  and the three structurally distinct "mutes"). Framework-neutral —
  no deployment-specific names or corpus references; consistent with
  `CLAUDE.md`'s separation-of-concerns rule.
- **`backend/app/docs.py`** — sibling loader for `philosophy.py`.
  Reads `DOCS.md` from the repo root with the same disk-first,
  in-process-cached, `refresh()`-on-demand shape. Optional
  `DOCS_PATH` env var points at an alternative source (e.g. a
  meta-repo working-tree clone) for deployments that prefer that.
- **`§17` endpoint** — `GET /api/docs` returns
  `{ "body": "<DOCS.md verbatim>" }`. Anonymous-reachable, same
  contract as `GET /api/philosophy`.
- **`frontend/src/components/Docs.jsx`** — the `/docs` reading
  surface. Mirrors `Philosophy.jsx`: chrome with Back / "USER GUIDE" /
  Home affordances, body rendered through `MarkdownPreview`.

### Changed

- **`backend/app/api.py`** — imports `docs as docs_mod` alongside
  `philosophy` in the relative-import block; registers the new
  `GET /api/docs` handler immediately after `GET /api/philosophy`.
- **`frontend/src/api.js`** — exports `getDocs()` alongside
  `getPhilosophy()`. Same fetch shape, different endpoint path.
- **`frontend/src/App.jsx`** — imports `Docs` alongside `Philosophy`,
  registers the `/docs` route alongside `/philosophy`, adds the
  persistent "Docs" header link alongside "About", and adds the
  `DocsWithSidebar` chrome wrapper alongside `PhilosophyWithSidebar`.

### Upgrade steps (from 0.13.0)

- You **MUST** rebuild the frontend and restart the backend after
  upgrading so the new `/docs` route, the new endpoint, and the new
  loader are picked up. `frontend/package.json#version` and `VERSION`
  both move to `0.14.0`. No schema migration; the new endpoint
  serves a checked-in file.
- You **MAY** set `DOCS_PATH` to an absolute path if your deployment
  hosts `DOCS.md` outside the framework's repo (e.g. as a sync target
  from a content repo). Unset is supported — the framework's
  `DOCS.md` at the repo root is the default, mirroring how
  `PHILOSOPHY_PATH` works for `/api/philosophy`.
- You **MAY** customize `DOCS.md` for your deployment if you want
  deployment-specific phrasing layered on top of the framework's
  guide. The file is a regular markdown source; standard `vim`/`git`
  edits suffice. Framework upgrades that ship a new `DOCS.md` will
  show as a normal merge in your deployment-overlay layer.

## 0.13.0 — 2026-05-28

**Minor — schema migration required; new optional env vars.** This
release ships the cookie / privacy consent surface (roadmap item #11,
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
now sees a non-modal bottom-of-page banner on first visit asking which
categories of cookies they allow (essential / essential + analytics /
essential + analytics + other). The choice persists in `localStorage`
for anonymous viewers and in a new `cookie_consent` table for
authenticated viewers, with server-side overriding local on sign-in.
The framework also ships default `/privacy` and `/cookies` policy pages
that deployments can layer their own policy URL on top of via two new
optional env vars. No analytics SDK ships in this release — the
consent infrastructure is wired so item #13 (v0.15.0) can read from
`frontend/src/lib/consent.js` when the SDK lands.

### Added

- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
  Non-modal, bottom of viewport. Three single-select choices with
  inline descriptions. Visible until the user makes a choice; hides
  thereafter. Reachable for revision via the settings surface.
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
  `getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
  `hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
  `storage` event. Item #13's analytics SDK reads consent here before
  importing.
- **Privacy and cookies policy pages**
  (`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
  Default minimal policies that describe the framework's stance and
  list the cookies the framework sets. Deployments override via the
  two new env vars below; the framework's stub always renders above
  the link so the framework-level contract stays visible.
- **"Privacy & cookies" tab** in `/settings/notifications` showing
  the current consent choice, the recorded-at stamp, and a "Change"
  button that re-opens the banner via a custom DOM event.
- **`§17` endpoints** —
  - `GET  /api/users/me/cookie-consent` — read the current consent
    record.
  - `PUT  /api/users/me/cookie-consent` — write a new consent record.
    Upserts a single row per user, stamps `recorded_at` to now,
    accepts `essential` for symmetry but always persists it as true.
- **Schema migration** `013_cookie_consent.sql` — new
  `cookie_consent` table keyed by `user_id`, three flags
  (`essential`, `analytics`, `other_cookies`), and `recorded_at`.
  (Renumbered from `012_*` during driver integration because v0.7.0
  also added a `012_otc.sql` migration that landed in the integration
  order before this one.)
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
  shape, the three-category single-select, the storage shape (local
  for anon, server row for authenticated), the precedence rule on
  sign-in, and the `consent.js` helper surface for downstream
  callers including item #13.
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
  `/privacy` and `/cookies` routes, the framework's stub content, and
  the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
  shape.
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
  app-tables list.
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
- **SPEC `§19.2`** — surfaces four candidates: policy content via
  content-repo file vs env var, GPC / DNT headers, multi-language
  consent text, and the item #13 analytics-SDK gating dependency.

### Changed

- **`frontend/.env.example`** — documents the two new optional env
  vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
  is supported; defaults render the framework's stub.
- **`backend/app/api_notifications.py`** — module docstring grew two
  endpoint lines; the new endpoints sit alongside the existing
  `/api/users/me/*` neighbors.
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
  routes (anonymous-reachable), wires `<CookieConsentBanner>` into
  the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
  custom event to re-open the banner from the settings surface.

### Upgrade steps (from 0.7.0)

- You **MUST** rebuild the frontend and restart the backend after
  upgrading. `frontend/package.json#version` and `VERSION` both move
  to `0.13.0` and the build embeds the new env-var contract.
- You **MUST** apply schema migration `013_cookie_consent.sql`. The
  migration creates a single new table keyed by `user_id` with three
  flag columns and a `recorded_at` stamp. The framework runs
  migrations automatically at process start; no manual step is
  required beyond restarting the backend so the migration runner
  picks the file up.
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
  points at your deployment's full privacy policy. The framework's
  `/privacy` page renders its built-in stub above a link to the
  configured URL. Unset is supported — the stub is sufficient for a
  default-config deployment.
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
  points at your deployment's full cookies policy. Same shape as the
  privacy URL.
- You **MAY** announce the new consent banner to your users. Existing
  authenticated users will see the banner on their next visit
  (because their `cookie_consent` row does not yet exist); their
  current sessions remain valid.

## 0.12.0 — 2026-05-28

**Minor — operator action required (new secret + new overlay).**
CloudFlare Turnstile gates the email-entry step of the OTC sign-in
flow against automated abuse (roadmap item #10, SPEC §6.2 / §19.2-
settled). Since v0.7.0 made `/auth/otc/request` the primary human-
auth path and v0.8.0 opened the request endpoint to any valid email,
the OTC dispatch became the natural target for distributed scrapers
fanning out to harvest "this email is admitted vs. this email is
not" timing/bounce signals. The per-email cooldown stops the trivial
back-to-back loop; the Turnstile challenge stops the distributed one
by costing the attacker a browser-side proof-of-humanness on every
request. The challenge runs before the bcrypt hash + SMTP send so a
failed verify spends no rate budget and produces no envelope.

Scope: the widget renders on the email-entry step of `/login` only.
The OTC verify step (where the user pastes the six-digit code) is
already bottlenecked on email delivery and protected by the
five-minute TTL + single-use consume on the row; a second challenge
there would double the rate budget against the same abuse path
without measurably more protection. If bots adapt to defeat the
email-entry challenge specifically — pushing the abuse vector onto
the verify step — a future release adds the second widget. The
widget also renders on the passcode step's "Use a code instead"
fallback dispatch since that route also calls `/auth/otc/request`.

Default policy: `TURNSTILE_REQUIRED=false`. The gate stays open when
the secret is absent — the dev / test path, and the pre-rollout
path while the operator is wiring the secret. Once the secret is in
GCP Secret Manager and the site key is in the overlay, the operator
**MAY** flip `TURNSTILE_REQUIRED=true` so a future config drift on
the secret fails loudly (HTTP 500 "auth misconfigured") instead of
silently disabling abuse defense.

No schema migration — Turnstile siteverify is stateless.

### Added

- **`backend/app/turnstile.py`** — the siteverify caller. POSTs
  `secret` + `response` (+ optional `remoteip`) to
  `https://challenges.cloudflare.com/turnstile/v0/siteverify` and
  returns a `VerifyOutcome` (`ok` boolean + `reason` enum:
  `ok` / `skipped` / `misconfigured` / `missing-token` / `failed` /
  `network`). Tunables read from env at call time so tests
  monkeypatch cleanly: `CLOUDFLARE_TURNSTILE_SECRET`,
  `TURNSTILE_REQUIRED`, and (test-only) `TURNSTILE_SITEVERIFY_URL`.
- **`frontend/src/components/TurnstileWidget.jsx`** — the React
  wrapper around the official CloudFlare Turnstile JS API. Reads the
  site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`; renders
  nothing when the var is unset (the form still submits and the
  backend's `TURNSTILE_REQUIRED` policy decides admission). Loads
  the CloudFlare script once per page on first widget mount. Cleans
  up the widget instance on unmount via `turnstile.remove()` so a
  remount produces a fresh challenge rather than reusing a stale,
  already-consumed token.
- **Backend tests** (`backend/tests/test_turnstile_vertical.py`) —
  five vertical scenarios: happy path (secret + valid token →
  admit), siteverify rejects → 400 + no envelope, missing-token →
  400 + no envelope, missing-secret-soft (default) → admit, and
  missing-secret-hard (`TURNSTILE_REQUIRED=true`) → 500
  "misconfigured". All five mock the siteverify HTTP call via
  `monkeypatch.setattr(turnstile.httpx, "post", …)`; no real
  CloudFlare keys are ever embedded.
- **SPEC `§6.2`** — names the Turnstile gate on the OTC dispatch as
  the v0.12.0 settled shape; the §19.2 candidate from v0.7.0 closes.

### Changed

- **`backend/app/main.py`** — `OtcRequestBody` grows an optional
  `turnstile_token` field. The `/auth/otc/request` handler calls
  `turnstile.verify_token` first, before `otc.request_code`, so a
  failed challenge spends no rate budget and produces no envelope.
  The handler maps `misconfigured` → HTTP 500, all other failures
  (`missing-token`, `failed`, `network`) → uniform HTTP 400 so the
  response does not enumerate which leg of the challenge broke.
- **`frontend/src/api.js`** — `requestOtc` accepts a second arg
  `{ turnstileToken }` and threads it into the request body. The
  positional signature stays backwards-compatible so calls that pass
  only an email still type-check.
- **`frontend/src/components/Login.jsx`** — the email step and the
  passcode step both render `<TurnstileWidget>`. The submit button
  on the email step is disabled until the widget produces a token
  (when the widget is enabled at build time); the "Use a code
  instead" link on the passcode step has the same gate. A 400 from
  `/auth/otc/request` clears the token and surfaces a "couldn't
  verify you're human, please retry" status. The fallback-from-
  passcode path bounces back to the email step on 400 so the user
  gets a fresh challenge in the natural place.
- **`backend/.env.example`** — documents `CLOUDFLARE_TURNSTILE_SECRET`
  and `TURNSTILE_REQUIRED` alongside the existing OTC tunables.
- **`frontend/.env.example`** — documents `VITE_TURNSTILE_SITE_KEY`
  with the operator wire-up procedure (dash.cloudflare.com →
  Turnstile → Add site).

### Upgrade steps (from 0.10.0)

The operator **MUST** create a CloudFlare Turnstile site
(dash.cloudflare.com → Turnstile → Add site, choose "Managed" widget
mode), obtain the site key (public) and secret key (private), and:

- You **MUST** `flotilla secret set ohm-rfc-app CLOUDFLARE_TURNSTILE_SECRET`
  (paste the secret key when prompted) before the v0.12.0 deploy.
  The framework reads the secret at request time; deploying v0.12.0
  without the secret leaves the gate in its default soft-fail state
  (every request admitted regardless of token), which means abuse
  defense is silently off.
- You **MUST** `flotilla overlay set ohm-rfc-app VITE_TURNSTILE_SITE_KEY <site-key>`
  so the frontend build embeds the site key and the widget renders
  on `/login`. The site key is public — it travels in the bundle and
  appears in every browser — so this is the overlay (non-secret)
  layer per the §3 invariant 1 split. Skipping this step leaves
  `/login` with no widget; even after the operator sets the secret,
  the backend would refuse every request as `missing-token` once
  `TURNSTILE_REQUIRED=true` flips.
- You **MUST** rebuild the frontend and restart the backend after
  upgrading. `frontend/package.json#version` and `VERSION` both move
  to `0.12.0`. No schema migration; Turnstile siteverify is
  stateless. The site-key embed is build-time, so the rebuild after
  the `flotilla overlay set` is what actually wires the widget into
  the bundle the deploy serves.
- You **MAY** `flotilla overlay set ohm-rfc-app TURNSTILE_REQUIRED true`
  once you've confirmed a real sign-in works end-to-end with the
  widget. The default (`false`) keeps the gate in soft-fail mode so
  a missing-secret regression admits requests rather than 500ing
  every sign-in attempt; flipping to `true` makes a future config
  drift on the secret fail loudly with HTTP 500 instead of silently
  disabling abuse defense. The framework's tested path is the
  flipped-to-true production shape; the default `false` exists for
  the dev / pre-rollout window only.
- You **MAY** customize the Turnstile widget mode (Managed /
  Non-interactive / Invisible) from the dashboard at any time
  without redeploying — the site key stays the same, and the widget
  picks up the mode change on the next page load. The framework's
  tested path is "Managed" because it gives the operator a visible
  challenge surface to debug against.

If either of the two **MUST** secret/overlay steps is skipped, the
deploy still boots and `/login` still serves; the failure mode is
that abuse defense is off (default `TURNSTILE_REQUIRED=false`) or
every sign-in attempt 500s (`TURNSTILE_REQUIRED=true` flipped while
the secret is unset). The driver pauses the wave at the secret/
overlay gesture so the operator confirms both are in place before
the framework version pin moves.

## 0.11.0 — 2026-05-28

**Minor — schema migration required; no new env vars.** This release
ships the "trust this device for 30 days" gesture (roadmap item #9,
SPEC §6.2). After a successful OTC or passcode sign-in, the user
can check a single checkbox to mint a server-issued opaque
device-trust token; the token rides as a long-lived HttpOnly +
Secure + SameSite=Lax cookie, and the matching row's hash lives in a
new `device_trust` table. On a subsequent visit, the cookie is
presented at `POST /auth/device-trust/start` — if a non-expired,
non-revoked row matches, the session is re-established without
another OTC / passcode roundtrip. A new `/settings/notifications`
"Trusted devices" section lists active rows (created-at, last-seen,
expiry, rough UA label) with per-row "Revoke" and a "Revoke all
devices" button. The cookie is "essential" per the v0.13.0 cookie-
consent contract — it is part of authentication, not analytics — and
is set regardless of the user's analytics / other-cookies choice.

The session model gains a cookie, not a session-store change: the
existing `rfc_session` cookie still carries the in-flight session
state; the new `rfc_device_trust` cookie is consulted only by
`/auth/device-trust/start` to bootstrap a fresh session on a return
visit. The raw token only ever lives in the outbound `Set-Cookie`
header and the inbound `Cookie` header; server-side storage is the
bcrypt hash; constant-time comparison via `bcrypt.checkpw` on the
candidate walk. The raw token is never logged.

### Added

- **`device_trust` table** (`backend/migrations/017_device_trust.sql`).
  Per-row id, `user_id` (FK with cascade), `device_token_hash`
  (bcrypt at rest, unique index documents the no-collision
  invariant), `created_at`, `expires_at` (`created_at + 30 days`),
  `user_agent` (verbatim, app-layer-truncated to 1024 chars),
  `last_seen_at` (refreshed on every successful lookup), `revoked_at`
  (NULL means active). Secondary index on `(user_id, revoked_at)` so
  the /settings list query is a covering walk.
- **`backend/app/device_trust.py`** — sibling of `otc.py` and
  `passcode.py`. Carries `issue(user_id, user_agent)`,
  `lookup(raw_token)`, `list_for_user(user_id)`, `revoke(user_id,
  row_id)`, and `revoke_all(user_id)`. The 30-day window and the
  cookie name (`rfc_device_trust`) live as module-level constants;
  env-ifying them is a §19.2 candidate.
- **`§17` endpoints**
  - `POST /auth/device-trust/start` — anonymous-reachable. Reads the
    `rfc_device_trust` cookie; on a hit, signs the user in. On a
    miss (expired, revoked, or unknown), clears the stale cookie and
    returns 401.
  - `GET  /api/auth/me/devices` — list active trusted devices for
    the signed-in user.
  - `DELETE /api/auth/me/devices/{id}` — revoke a single row.
    User-id scope enforced in SQL so a hostile client cannot
    revoke another user's row by guessing ids.
  - `DELETE /api/auth/me/devices` — revoke every active row.
- **OTC and passcode verify bodies** gain an optional
  `trust_device: bool` field (default false). When true and verify
  succeeds, the endpoint mints a fresh device-trust row and sets
  the cookie on the response. Pre-v0.11.0 clients that omit the
  field continue to behave as before.
- **Login.jsx** gains a "Trust this device for 30 days" checkbox
  on both the OTC and passcode verify steps, plus a silent on-mount
  call to `POST /auth/device-trust/start` so a returning user with
  a valid cookie skips the email step entirely. A failure is
  intentionally invisible — the user proceeds to the normal email
  step.
- **`/settings/notifications` "Trusted devices" section** — lists
  active rows with per-row "Revoke" + a "Revoke all devices" button
  (with a `confirm()` prompt because the gesture is broad). The
  surface intentionally does not single out the row whose cookie
  the current request carries so a user can revoke "this device"
  alongside any other from one place.

### Changed

- **`backend/app/main.py`** — the OTC and passcode verify endpoints
  now also accept the `trust_device` flag and accept an injected
  `Response` so they can attach the cookie. Two helpers
  (`_set_device_trust_cookie`, `_clear_device_trust_cookie`) carry
  the cookie attribute set in one place so the contract is
  consistent across endpoints. The `Response` import is added
  alongside the existing FastAPI re-exports.
- **`backend/app/api.py`** — imports `device_trust as device_trust_mod`
  alongside `auth`/`db`; mounts the three `/api/auth/me/devices*`
  endpoints immediately after `/api/auth/me/beta-request` so the
  auth-shaped neighborhood stays clustered.
- **`frontend/src/api.js`** — exports `startDeviceTrust()`,
  `listMyDevices()`, `revokeMyDevice(id)`, `revokeAllMyDevices()`.
  `verifyOtc` and `verifyPasscode` accept an optional
  `{ trustDevice }` argument that rides on the POST body.

### Upgrade steps (from 0.10.0)

- You **MUST** apply schema migration `017_device_trust.sql`. The
  migration creates a single new table with one secondary index;
  the framework runs migrations automatically at process start, so
  no manual step is required beyond restarting the backend so the
  migration runner picks the file up.
- You **MUST** rebuild the frontend and restart the backend after
  upgrading. `frontend/package.json#version` and `VERSION` both
  move to `0.11.0` and the new `Set-Cookie` shape requires the
  backend to be on the matching version.
- You **MUST** serve the deployment over HTTPS. The
  `rfc_device_trust` cookie is set with `Secure=True` — a
  cleartext deployment will never receive the cookie back from
  the browser, so the trust gesture will appear to silently fail.
  Production OHM deployments already serve over HTTPS; local
  development against `http://localhost` is unaffected (no cookie
  is set, the OTC/passcode paths continue to work).
- You **MAY** announce the new feature to your users. Existing
  signed-in sessions are unaffected — the device-trust cookie is
  opt-in on the next sign-in, and a user who never checks the box
  keeps the v0.10.0 behavior verbatim.


## 0.10.0 — 2026-05-28

**Minor — schema migration required; new auth path is additive.**
This release lands user-set passcodes after OTC (roadmap item #8,
SPEC §6.2). After a successful one-time-code sign-in, the user can
set a passcode (4–20 characters) and use email + passcode for
subsequent sign-ins. OTC remains the structural fallback: a
forgotten passcode is recovered by requesting a fresh code, and
five consecutive failed passcode verifies lock the passcode path
for 15 minutes (HTTP 423) while leaving the OTC path open. The
`/login` surface now consults a new `GET /auth/passcode/check`
endpoint after the email step to decide whether to render a
passcode input or an OTC code input; an "Use a code instead" link
on the passcode step lets the user fall back to OTC manually. The
`/settings/notifications` page grew a new "Sign-in" tab where the
user can set, change, or remove their passcode.

### Upgrade steps (from 0.8.0)

1. **MUST** restart the backend so migration `015_passcode.sql`
   runs. The migration adds four nullable columns to the `users`
   table: `passcode_hash`, `passcode_set_at`,
   `passcode_failed_attempts` (default 0), `passcode_locked_until`.
   Existing rows pass through with `passcode_hash = NULL`, which
   the runtime treats as "no passcode set" — every existing user
   continues to sign in via OTC unchanged, and can opt into a
   passcode from the new settings tab at any time.
2. **MUST** rebuild the frontend so the v0.10.0 `/login` flow and
   the new settings tab ship. `frontend/package.json#version` and
   `VERSION` both move to `0.10.0`.
3. **SHOULD** announce the new sign-in option to users. Wording
   suggestion: "You can now set a passcode for faster sign-in.
   We'll keep emailing one-time codes as a fallback — if you
   forget your passcode, just request a code as usual."
4. **MAY** leave the §6.2 default lockout shape (5 attempts,
   15-minute window) unchanged. v0.10.0 does not expose env
   tunables for these; raising or lowering them lives in §19.2
   as a candidate.

### Added

- **`POST /auth/passcode/set`** — authenticated. Body `{passcode}`.
  Validates length (4–20) and refuses obvious patterns from a small
  denylist (`0000`, `1234`, `aaaa`, `password`, etc.). bcrypt-hashes
  the passcode and writes `users.passcode_hash` plus
  `users.passcode_set_at`. Clears any active lockout and the
  failure counter (a user setting a fresh passcode is implicitly
  re-authenticating). Replaces any prior passcode.
- **`DELETE /auth/passcode`** — authenticated. Clears the passcode
  hash and the set-at stamp; the user is back to OTC-only.
- **`POST /auth/passcode/verify`** — unauthenticated. Body
  `{email, passcode}`. Returns HTTP 200 + minimal user payload on
  success; HTTP 423 with `locked_until` when the account is in the
  lockout window; HTTP 400 for every other failure (the
  wrong-passcode and unknown-email modes both collapse to 400 so
  the response does not enumerate account state).
- **`GET /auth/passcode/check`** — unauthenticated. Query param
  `email`. Returns `{has_passcode: boolean}`. The Login.jsx flow
  consults this after the email step to decide whether to render a
  passcode input or fall back to OTC. The response carries only
  the boolean; lockout state, the hash, and the `passcode_set_at`
  stamp are not leaked. An unknown email and a known-without-
  passcode email both return `false`, so the endpoint is
  account-enumeration-safe.
- **Schema migration** `015_passcode.sql` — four ALTER TABLE ADD
  COLUMN statements on the `users` table:
  - `passcode_hash TEXT` (nullable) — the bcrypt hash. NULL means
    "no passcode set".
  - `passcode_set_at TEXT` (nullable) — ISO-8601 timestamp.
  - `passcode_failed_attempts INTEGER NOT NULL DEFAULT 0` —
    consecutive failure counter since last success.
  - `passcode_locked_until TEXT` (nullable) — lockout window
    expiry; verify refuses with HTTP 423 while populated and
    in the future.
- **`backend/app/passcode.py`** — the passcode state machine:
  validation (length + denylist), bcrypt hashing, set/clear,
  status check, and the verify path with lockout management.
- **`frontend/src/components/Login.jsx`** — extended to a five-step
  surface: email → passcode-or-code → optional post-OTC
  passcode-offer → optional set-passcode. The "Use a code instead"
  link on the passcode step re-dispatches an OTC and switches to
  the code step. A 423 from passcode verify auto-falls back to OTC
  with a visible status message.
- **"Sign-in" tab** in `/settings/notifications` — shows
  passcode-set status, the recorded `passcode_set_at` stamp when
  set, and Set / Change / Remove buttons. Mirrors the §14.5
  "Privacy & cookies" tab pattern.
- **SPEC `§6` / `§14.1` / `§17` / `§19.2`** corrections per §19.3
  rule 2:
  - §6 names the three current auth paths (OTC, passcode-with-OTC-
    fallback, OAuth-fallback-during-migration).
  - §14.1 documents the stepped `/login` surface and the passcode
    check endpoint.
  - §17 lists the four new `/auth/passcode/*` endpoints.
  - §19.2 surfaces four new candidates (passcode policy tunables
    via env, per-IP rate-limit on `/auth/passcode/verify`,
    passcode-change "old passcode" challenge, passkey/WebAuthn);
    the "device-trust 30d" entry's passcode cross-ref is updated;
    the "first-OTC profile capture" entry's roadmap cross-ref is
    updated.

### Changed

- **`backend/app/main.py`** — registers the four new
  `/auth/passcode/*` routes on the existing oauth router, alongside
  the v0.7.0 `/auth/otc/*` routes.
- **`backend/app/api.py`** — `/api/auth/me` payload now includes
  `has_passcode` (boolean) and `passcode_set_at` (string or null)
  so the settings surface can render the Set / Change / Remove
  affordances without a second round trip.
- **`frontend/src/api.js`** — adds `checkPasscode`, `verifyPasscode`,
  `setPasscode`, `clearPasscode` helpers, neighboring the v0.7.0
  `requestOtc` / `verifyOtc` block.
- **`frontend/src/components/NotificationSettings.jsx`** — adds the
  `SignInSection` component between `MutesSection` and
  `PrivacyCookiesSection`.

### Tests

- **`backend/tests/test_passcode_vertical.py`** — 17 new tests
  cover: set requires session; check returns false for unknown and
  for set-less users; check returns true after set without leaking
  other fields; happy-path OTC → set → verify roundtrip; wrong
  passcode increments the counter without locking; five consecutive
  failures lock with 423 and persist `passcode_locked_until`;
  lockout expires and the next attempt clears the counter; the OTC
  path is unaffected by passcode lockout; clear wipes the hash and
  set-at; setting a new passcode replaces the prior one and resets
  the lockout; `passcode_set_at` updates on every set; validation
  refuses too-short passcodes and denylist patterns; `/api/auth/me`
  carries `has_passcode` and `passcode_set_at` correctly.

### Environment variables

None new. The existing `SECRET_KEY` continues to sign session
cookies; passcode hashing reuses the bcrypt dependency added in
v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
range (4–20) are hard-coded in `backend/app/passcode.py`. See
§19.2 for the env-tunable candidate.

## 0.9.0 — 2026-05-28

**Minor — no schema migration; reuses v0.8.0 columns and existing
SMTP.** This release ships the admin user-management surface at
`/admin/users` and the new-beta-request notifications that feed
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
compose: admins receive an inbox + email signal the moment a
pending user submits the v0.8.0 capture form; clicking through
lands on the page where they Grant or Revoke access.

The page consumes the v0.8.0 schema columns
(`permission_state`, `first_name`, `last_name`,
`beta_request_reason`, `permission_decided_by`,
`permission_decided_at`) without adding new ones — migration
slot 016 stays reserved for a future release. The single new
write endpoint, `POST /api/admin/users/<id>/permission`,
replaces v0.8.0's documented manual `UPDATE users SET
permission_state='granted'` gesture with an audited UI flip.

Admin notifications ride the existing §15 chokepoint — the
`new_beta_request` event_kind is added to the enum with
category `admin-actionable`, fan-out is to every owner / admin
minus the requester themselves, and the §15.4 email dispatch
only reaches recipients whose `email_admin_actionable` toggle
is on (the default for owners + admins). The event is the
framework's first non-RFC-scoped notification — `rfc_slug` is
NULL and the email deep-link points `/admin/users` instead of
`/rfc/<slug>`.

Decision on `/admin/allowlist`: the surface stays as a
sibling sub-tab, not folded into `/admin/users`. The two have
different keys (allowlist by email pre-sign-up, user list by
user_id post-sign-up) and a union row would be confusing
rather than clarifying. The allowlist's fast-path-bypass role
from v0.8.0 is unchanged; retiring the table outright is
deferred to a later session (see §19.2).

### Upgrade steps (from 0.10.0)

1. **MAY** rebuild the frontend. The build is the same shape
   as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
   in `frontend/` updates it cleanly. No new env vars on the
   frontend; the existing `VITE_APP_NAME` requirement carries
   over.
2. **MAY** restart the backend. No schema migration runs in
   this release — every v0.9.0 column is from
   `014_beta_access.sql` (v0.8.0). Restart only if you want
   the new endpoints registered in this version's binary.
3. **SHOULD** verify SMTP can reach the deployment's admin
   inbox before the first pending user submits the capture
   form. v0.9.0 reuses the v0.7.0 SMTP configuration
   (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
   `SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
   misconfigured deployment will still write the inbox row,
   but the admin won't hear about it through email. No new
   env var is required — the framework reuses the existing
   admin-user list (role IN ('owner', 'admin')) as the
   notification recipients.
4. **SHOULD** announce the surface to existing admins. Wording
   suggestion: "There's now a Users tab in /admin where you can
   Grant or Revoke beta access — and you'll get an email +
   inbox row when a fresh request lands. The manual SQL gesture
   from v0.8.0 still works but is no longer the documented
   path."
5. **MAY** drain the existing pending queue through the new UI.
   If your deployment carried pending users through the v0.8.0
   manual-UPDATE window, the Pending bucket on `/admin/users`
   surfaces all of them with their captured profile. Granting
   from the UI stamps `permission_decided_by` /
   `permission_decided_at`, which any prior manual UPDATE
   gestures may have left NULL (no harm done — the v0.8.0
   contract didn't require those stamps).

### Added

- **`POST /api/admin/users/<id>/permission`** — body
  `{state: 'pending'|'granted'|'revoked'}`. Flips the column,
  stamps `permission_decided_by` + `permission_decided_at`,
  and writes a `permission_events` row with event_kind in
  `{permission_granted, permission_revoked,
  permission_repended}`. Refuses 422 on self-flip (symmetric
  to `set_mute` / `set_role` self-action refusals) and 422
  on invalid state. Returns `{ok, permission_state, changed}`
  where `changed=false` indicates a no-op (the requested
  state already matched).
- **Widened `GET /api/admin/users` response** carrying
  `permission_state`, `first_name`, `last_name`,
  `beta_request_reason`, `created_at`,
  `permission_decided_at`, plus joined
  `permission_decided_by_login` /
  `permission_decided_by_display`. Sort order surfaces
  `pending` rows first (the admin queue), then `granted`,
  then `revoked`; within a bucket, owners precede admins
  precede contributors, with recency as the tiebreaker.
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
  by `notify.fan_out_new_beta_request` from the first
  successful `POST /api/auth/me/beta-request` (re-submits
  from the same pending user don't re-fire — the row's
  first-time-complete check guards against carpet-bombing).
  Recipients: every owner + admin minus the requester
  themselves. Category: `admin-actionable`. Deep-link:
  `/admin/users`. Actor: the requester per §15.9.
- **`/admin/users` page enhancements** in `Admin.jsx`. The
  Users tab gains a state filter chip row (All / Pending /
  Granted / Revoked with counts), a Grant / Revoke control
  column, a Permission state badge, and an expandable
  "why they want access" row beneath each pending user.
  Sign-up timestamp surfaces in a new column.
- **`frontend/src/api.js#setUserPermission`** — client for
  the new endpoint, neighboring `setUserMute` and
  `setUserRole`.
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
  "your request is in review" page now honestly references
  the admin-email signal v0.9.0 ships and admits the
  framework does not commit to an SLA — turnaround depends
  on operator availability, and the deployment operator is
  the right person to ask if a wait runs long. No
  deployment-specific text is baked in; per-deployment copy
  lives in §13 of the deployment's repo, not in the
  framework.
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
  tests covering: a beta-request submission fans
  notifications to every admin + owner (and not to the
  requester or to contributors); the requester's profile
  fields land in the notification payload; re-submitting
  the capture form does not re-fan; the `admin-actionable`
  category mapping is wired; the `/api/admin/users`
  listing carries the v0.8.0 columns with `pending` rows
  sorted first; the permission-flip endpoint promotes
  pending → granted with the right audit shape; the
  endpoint promotes granted → revoked; the endpoint
  refuses self-flip with 422; the endpoint refuses
  non-admin callers with 403 and anonymous callers with
  401; the endpoint refuses invalid states with 422; a
  state-already-matches flip returns `changed=false`
  without writing an audit row.

### Changed

- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
  handler now calls `notify.fan_out_new_beta_request` after the
  capture UPDATE lands, gated on the row not previously having
  all three profile fields populated (so re-submits don't
  re-fan).
- **`backend/app/api_admin.py`** — the `list_users` query joins
  against `users d ON d.id = u.permission_decided_by` for the
  deciding-admin handle. The new `set_permission` endpoint
  lives alongside `set_role` / `set_mute`.
- **`backend/app/notify.py`** — adds
  `CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
  helper, and the `new_beta_request` arm in `render_summary`.
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
  carries `new_beta_request → admin-actionable`, and
  `_deep_link` routes framework-scoped admin signals to
  `/admin/users` instead of `/rfc/<slug>`.
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
  component is rewritten with state filter chips, a per-row
  `UserRow` + `PermissionCell` decomposition, and a
  `useMemo`-cached counts table. The pending-row reason
  blockquote renders as a secondary `<tr>` beneath the user
  row when present.
- **`frontend/src/components/BetaPending.jsx`** — pending-state
  copy revised to reference the admin email signal honestly.
- **`frontend/src/App.css`** — new admin-chip / permission-badge
  / user-row-reason rules.
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
  endpoints, §19.2 candidates list — per §19.3 rule-2.
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
  the lockfile mirror.

### Environment variables

None new. The release reuses the v0.7.0 SMTP configuration
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
admin-user list (role IN ('owner', 'admin')) as the
notification recipients. A deployment whose SMTP is misconfigured
will still see the inbox rows; only the email channel is muted.

### Deferred to later releases

- **Grant / revoke notification to the user** — symmetric
  signal: when an admin grants or revokes access, fire a
  `personal-direct` notification (event_kind
  `permission_change_affecting_me`, already in the §15.1
  enum) so the affected user sees the state change in
  their inbox and email. v0.9.0 audits the gesture in
  `permission_events` but does not yet escape the
  app-internal log to the user. See §19.2.
- **Decline-with-reason on revoke** — the current Revoke
  gesture takes only a confirmation; a follow-up release
  may capture a free-text reason in
  `permission_events.details`. See §19.2.
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
  stays in place in v0.9.0 (the two surfaces have different
  keys and a union row would be confusing). Retiring the
  table outright is deferred to a session that can re-read
  the post-v0.9.0 operator experience and decide whether
  the fast-path-bypass role is still pulling weight. See
  §19.2.

## 0.8.0 — 2026-05-28

**Minor — schema migration required; admission semantics shift.**
This release replaces the v0.3.0 / v0.7.0 `allowed_emails` admission
gate with an admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 /
§14.1 / §17). Anyone with a valid email can sign in via the v0.7.0
OTC flow; the OTC request endpoint no longer consults the
allowlist. A fresh user lands in `permission_state='pending'` until
an admin grants access. The first-OTC sign-in captures first name,
last name, and a free-text "why I should be included in the beta"
via a new `POST /api/auth/me/beta-request` endpoint; the captured
fields populate the same `users` row alongside the OAuth-era
columns.

A pending user has the same read access an anonymous viewer has —
the catalog, RFC bodies, the philosophy page, and every public
conversation are reachable. Every write-shaped endpoint
(`auth.require_contributor` floor) refuses pending users with 403.
The frontend renders a thin "Your beta access request is in
review" banner on every page and re-purposes the v0.3.0
`/beta-pending` page as the post-capture landing surface.

Grandfathered behavior: every `users` row at migration time
carries `permission_state='granted'` via the column default, so
existing contributors are unaffected. The OAuth fallback at
`/auth/callback` still consults the v0.3.0 allowlist (legacy
path); the OTC flow does not.

The `allowed_emails` table stays in the schema as a fast-path
bypass — the v0.3.0 admin UI continues to manage it, but the OTC
request handler no longer reads it. v0.9.0 (roadmap item #7) is
expected to ship the admin user-management page that replaces the
allowlist surface entirely; until then, admin grants are done by
direct DB `UPDATE`.

### Upgrade steps (from 0.13.0)

1. **MUST** restart the backend so migration `014_beta_access.sql`
   runs. The migration adds `permission_state` (default `'granted'`,
   so existing rows pass through unaffected), `first_name`,
   `last_name`, `beta_request_reason`, `permission_decided_by`,
   and `permission_decided_at` to the `users` table, plus an index
   on `permission_state` for the pending queue. The migration is
   ALTER-TABLE-based (no table rebuild) — every foreign key and
   existing row passes through untouched.
2. **MUST** rebuild the frontend. The `Login.jsx` surface now
   runs a conditional third step (the capture form) on fresh OTC
   sign-ins; `BetaPending.jsx` carries the new "your request is
   in review" copy; `App.jsx` renders a thin pending-access
   banner. The build embeds the new `/api/auth/me/beta-request`
   client call.
3. **SHOULD** announce the new admission flow to existing users.
   Wording suggestion: "We've replaced our email-allowlist gate
   with an admin-review flow. Existing users are unaffected;
   new visitors sign in with their email, tell us a bit about
   themselves, and an admin reviews their request before
   discussion and contribution unlock." Existing sessions
   remain valid.
4. **SHOULD** plan the admin grant mechanism. v0.8.0 does not
   ship a UI for the grant — v0.9.0 (roadmap item #7) will. For
   the v0.8.0 window, an admin grants access via direct DB
   gesture:
   ```sql
   UPDATE users
      SET permission_state = 'granted',
          permission_decided_by = <admin_user_id>,
          permission_decided_at = datetime('now')
    WHERE email = '<approved>';
   ```
   The pending queue lives in `SELECT * FROM users WHERE
   permission_state = 'pending' ORDER BY created_at`.
5. **MUST** decide whether to drain the `allowed_emails` table.
   The OTC request handler no longer consults it; populated
   rows are inert at the request surface. Three operator
   choices, all valid:
   * **Leave as-is** (the framework's default behavior — the
     v0.3.0 admin UI continues to work, the rows stay as a
     fast-path bypass record). Recommended if you anticipate
     v0.9.0's user-management page folding the allowlist UI
     into its surface.
   * **Drain via the existing admin UI** (`/admin/allowlist`)
     — one row at a time, no data loss elsewhere.
   * **Bulk-drain via DB** — `DELETE FROM allowed_emails;`
     drops every row; the table stays.
6. **MAY** announce write access individually to grandfathered
   users you want to keep at `'granted'`. The default-`'granted'`
   migration means no action is required for them; this step
   exists only if you want to send a "you're still in" message.

### Added

- **`backend/migrations/014_beta_access.sql`** — adds
  `permission_state` (CHECK in `('pending', 'granted', 'revoked')`,
  default `'granted'`), `first_name`, `last_name`,
  `beta_request_reason`, `permission_decided_by` (FK to users,
  ON DELETE SET NULL), `permission_decided_at` to the `users`
  table. Plus `idx_users_permission_state` for the pending
  queue.
- **`POST /api/auth/me/beta-request`** — body
  `{first_name, last_name, beta_request_reason}` (all required;
  bounds 120 / 120 / 4000). Writes the fields to the signed-in
  user's row and leaves `permission_state='pending'`. Refuses
  HTTP 409 for already-granted / revoked users; refuses HTTP
  401 for anonymous callers.
- **`needs_profile` flag** on the `/auth/otc/verify` response.
  `true` iff the user is `permission_state='pending'` AND
  carries no profile fields yet (a fresh OTC sign-in). The
  Login.jsx surface uses the flag to gate the capture step.
- **`permission_state` field** on the `/api/auth/me` response,
  plus `first_name`, `last_name`, `beta_request_reason`, and
  the same `needs_profile` flag.
- **First-OTC profile capture step** in `Login.jsx`. Third
  step in the sign-in surface, gated by the verify response's
  `needs_profile` flag.
- **`/beta-pending` repurpose** in `BetaPending.jsx`. The
  page now reads as "your request is in review" when the
  viewer is pending; the v0.3.0 "private beta" framing
  remains as the anonymous-viewer fallback.
- **Thin pending-access banner** at the top of every page
  for `permission_state='pending'` viewers (other than
  `/beta-pending` itself).
- **SPEC `§6` opening / `§6.1` / `§6.2` / `§14.1` / `§17` /
  `§19.2`** corrections per §19.3 rule-2 — the admission
  shift, the orthogonality of permission_state vs role / muted
  / notification-mutes, the new endpoints, and the
  newly-surfaced §19.2 candidates (admin user-management page,
  allowlist deprecation, admin notification on new request).
- **`backend/tests/test_beta_access_vertical.py`** — 9 new
  tests covering: a fresh OTC user lands pending with empty
  profile; the capture endpoint populates the fields and
  keeps state pending; the capture endpoint refuses
  anonymous / granted / revoked callers; a pending user is
  refused write endpoints; an admin grant promotes pending →
  granted; a grandfathered user is unaffected by the
  migration; the OTC request endpoint accepts any email
  regardless of allowlist state; the `allowed_emails` table
  is still present in the schema.

### Changed

- **`backend/app/auth.py#require_contributor`** widens its
  gate to refuse `permission_state != 'granted'` with HTTP
  403. The §6.1 contributor capabilities (propose, branch,
  PR, chat, claim) all funnel through this dependency, so
  the widening covers them transitively. `SessionUser` now
  carries `permission_state` (default `'granted'` for the
  dataclass-default fallback path).
- **`backend/app/otc.py#request_code`** drops the allowlist
  check from the OTC request flow. The `RequestOutcome`
  shape loses the `'allowlist'` reason (replaced by
  `'sent'` / `'cooldown'` / `'invalid'`).
- **`backend/app/otc.py#provision_or_link_user`** sets
  `permission_state='pending'` explicitly on a fresh row.
  Grandfathered (link-by-email) users pass through with
  their existing column value.
- **`backend/app/auth.py#provision_user`** (OAuth fallback)
  now sets `permission_state='granted'` explicitly on a
  fresh row. The OAuth callback still consults the
  `is_allowed_sign_in` allowlist check (the legacy fallback
  path retains its v0.3.0 admission shape during the OAuth
  migration window).
- **`backend/tests/test_otc_vertical.py`** — the
  `test_otc_request_silently_drops_when_email_not_on_allowlist`
  test (asserted the v0.7.0 allowlist gate) is replaced by
  `test_otc_request_admits_emails_regardless_of_allowlist_population`
  which asserts the v0.8.0 open-request contract. The
  on-list test stays as a regression net for the
  rate-limit / outbound-buffer plumbing.
- **`SPEC.md`** §6 opening, §6.1, §6.2, §14.1, §17, §19.2
  per §19.3 rule-2.
- **`VERSION`** → `0.8.0`. `frontend/package.json#version` and
  the lockfile mirror.

### Deferred to later releases

- **Admin user-management page** at `/admin/users` (item #7,
  v0.9.0) — replaces the manual DB `UPDATE` gesture.
- **Allowlist UI deprecation** (also v0.9.0) — once the
  admin user-management page lands, the `/admin/allowlist`
  surface and the `allowed_emails` table both retire.
- **Admin email notification on new beta request** (item #7
  again, v0.9.0).
- **Revoke gesture in the UI** — the `permission_state='revoked'`
  state is wired in the schema and the auth gate; v0.9.0 ships
  the admin UI that flips the column.

## 0.7.0 — 2026-05-28

**Minor — schema migration required; new auth path is additive.**
This release lands email + one-time-code sign-in (roadmap item #5,
SPEC §6.2) as the primary human-auth gesture. Users sign in by
typing their email, receiving a six-digit code via email, and
entering it. The Gitea OAuth callback (`/auth/callback`) remains
functional during migration — the new UI no longer points at it
primarily, but a "Sign in with Gitea (fallback)" link survives on
the new login surface so users with active OAuth sessions or older
invite paths still have a way in. A future release retires the
OAuth path entirely once every active user has signed in at least
once via OTC.

The migration path for existing users: first OTC sign-in matches by
`users.email` (case-insensitive) to the OAuth-era row and reuses
that row's `id` and `gitea_id`. New users provisioned via OTC carry
`gitea_id = NULL` and `gitea_login = NULL`. The `gitea_id` linker
remains the canonical handle for grandfathered users; `email`
becomes the identity key for everything provisioned after v0.7.0.

The Gitea **bot** user + token are still required (server-side git
operations — repo reads, PR creation — still flow through it). Only
the operator-facing sign-in surface moves.

### Upgrade steps (from 0.6.0)

1. **MUST** restart the backend so migration `012_otc.sql` runs.
   The migration rebuilds the `users` table (SQLite cannot ALTER
   COLUMN); existing rows pass through unchanged, but the new
   schema relaxes `gitea_id` / `gitea_login` to nullable (with
   partial unique indexes that ignore NULL) and adds a partial
   unique index on `email`. A new `otc_codes` table is created.
2. **MUST** confirm the SMTP overlay is set (`SMTP_HOST`,
   `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_STARTTLS`,
   `EMAIL_FROM`, `EMAIL_FROM_NAME`). The OHM overlay already
   carries these as of v0.5.0; deployments without them fall back
   to logging the code to stdout (dev-only path — production
   visitors will not receive their codes).
3. **SHOULD** announce the new email-based sign-in to existing
   users. Wording suggestion: "You can now sign in by entering
   your email and a one-time code we'll send you. Your old
   account is linked automatically the first time you sign in."
4. **MAY** keep the existing OAuth callback as a fallback path.
   The new login UI surfaces a small "Sign in with Gitea
   (fallback)" link beneath the primary email/code form; a
   deployment that prefers to hide it can override the Login
   component in a future framework release that exposes the link
   behind a feature flag. For v0.7.0, the link is hard-coded.

### New environment variables (all optional with defaults)

- `OTC_TTL_MINUTES` (default `10`) — how long a one-time code is
  valid after issuance. Re-requesting invalidates the prior code
  immediately regardless of TTL.
- `OTC_REQUEST_COOLDOWN_SECONDS` (default `60`) — per-email cooldown
  between successive `/auth/otc/request` calls. The endpoint returns
  HTTP 429 when the cooldown blocks a request (the loud-failure
  shape; the abuse path is visible rather than swallowed).

No new secrets are required. The existing `SECRET_KEY` continues to
sign session cookies; OTC codes are bcrypt-hashed at rest using a
per-row salt the library generates.

### Added

- **`POST /auth/otc/request`** — body `{email}`. Generates a six-digit
  code, stores its bcrypt hash with an expiry, and dispatches a plain
  text email via the existing SMTP layer. Returns HTTP 200 (`{ok:true}`)
  uniformly so allowlist state is not leaked. Returns HTTP 429 when
  the per-email cooldown blocks the request.
- **`POST /auth/otc/verify`** — body `{email, code}`. Validates the
  bcrypt hash against the most-recent unconsumed non-expired row, marks
  the row consumed, provisions or links the `users` row by email, and
  stores the session cookie. Returns HTTP 200 on success, HTTP 400 on
  any failure (expired, consumed, wrong, unknown).
- **`backend/migrations/012_otc.sql`** — creates `otc_codes` and
  rebuilds `users` with nullable `gitea_id` / `gitea_login` plus a
  partial unique index on `email`.
- **`backend/app/otc.py`** — the OTC request/verify state machine and
  the `provision_or_link_user` linker.
- **`backend/app/email_otc.py`** — outbound OTC mail composition. Reuses
  the SMTP plumbing from `email.py` (`EmailConfig.from_env()`) and the
  test buffer (`_SENT`) but writes its own envelope (no unsubscribe
  footer, no quiet-hours hold — OTC mail carries a credential and
  ignores notification preferences).
- **`frontend/src/components/Login.jsx`** — two-step sign-in surface
  at `/login`. Step 1: enter email → request code. Step 2: enter
  six-digit code → verify. Cmd/Ctrl+Enter on the code field submits.
  The previous header "Sign in" link and the `Welcome` component's
  inline link now route to `/login` instead of jumping straight to
  the Gitea OAuth dance.
- **SPEC `§6.1` / `§6.2` / `§14.1` / `§17` / `§19.2`** corrections per
  §19.3 rule-2 — see below.
- **`backend/tests/test_otc_vertical.py`** — 11 new tests covering
  the happy path, expired/consumed/wrong codes, the per-email rate
  limit (and its per-email isolation), the allowlist gate, the
  migration link to OAuth-era users, fresh provisioning, and the
  prior-code-invalidation behavior on re-request.

### Changed

- **`backend/app/auth.py#current_user`** — coerces NULL `gitea_id` and
  NULL `gitea_login` to `0` / `""` so the `SessionUser` shape stays
  stable for OTC-only users. The DB remains the source of truth for
  "is this user OAuth-linked" (`gitea_id IS NOT NULL`).
- **`backend/requirements.txt`** — adds `bcrypt>=4.2` for OTC code
  hashing. Pure-Python wheels are available on every platform the
  deployment matrix targets; `pip install -r backend/requirements.txt`
  picks it up.
- **`frontend/src/App.jsx`** — adds the `/login` route and replaces
  the header "Sign in" `<a href="/auth/login">` with `<Link to="/login">`.
  The `Welcome` component's inline sign-in link follows suit.
- **`frontend/src/components/Landing.jsx`** — the `/welcome` page's
  primary action moves from "Sign in with Gitea" to "Sign in" pointing
  at `/login`.

### Deferred to later releases

Per the v0.7.0 scope discipline (the foundation for items #6, #8, #9,
#10), several adjacent capabilities are intentionally not in this
release and surface as §19.2 candidates:

- **First-OTC profile capture** (first name, last name, "why") — item
  #6, expected v0.8.0.
- **Open beta-access request flow** replacing the allowlist gate —
  also item #6, v0.8.0.
- **Passcodes** (a long-term reauth token alternative) — item #8,
  expected v0.10.0.
- **Device-trust 30-day skip** — item #9, expected v0.11.0.
- **Cloudflare Turnstile** on `/auth/otc/request` — item #10,
  expected v0.12.0.
- **Removing the Gitea OAuth `/auth/callback` route entirely** — a
  later release after every active user has signed in via OTC.

## 0.6.0 — 2026-05-28

**Minor — no operator action required.** A sweep-the-edges hardening
release (roadmap item #4, "anon discuss + contribute off-limits") that
audits every write-shaped backend endpoint and asserts each one
enforces an explicit `auth.require_contributor` (or stricter) gate
before doing any state-changing work. v0.3.0 hid write affordances
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
discussion surface with its own write gate; v0.6.0 sweeps the rest
and adds a regression test net so future endpoints can't quietly
ship without a gate. No schema migration, no env-var changes, no
new dependencies. The only behavioural change is one tightening: the
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
`auth.require_user` since it surfaces operator-visible step detail
(repo name, PR number, rollback steps) not part of the v0.3.0
anonymous-read contract for catalog/RFC bodies.

### Changed

- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
  now calls `auth.require_user(request)` as its first line.
  Anonymous callers receive 401 instead of being able to subscribe
  to a graduation's SSE. The floor is `require_user` (not
  `require_contributor`) so a write-muted operator can still observe
  the progress of a graduation they kicked off before being muted.
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
  documents the v0.6.0 audit: every write-shaped endpoint in §17
  enforces an explicit gate; anonymous writes refuse 401. The list
  of audited write families is recorded in-line.
- **SPEC `§10.10`** — the discussion-vs-contribution section now
  records that the v0.5.0 write gates have a regression test net
  (`test_anon_offlimits_vertical.py`) added in v0.6.0.
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
  bullet now documents the `require_user` gate added in v0.6.0,
  with the rationale.

### Added (tests)

- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
  tests asserting that every write-shaped endpoint surveyed in the
  v0.6.0 audit refuses anonymous callers with 401, and that the five
  anonymous-read surfaces (health, philosophy, auth/me, catalog,
  RFC view, discussion threads, proposals) stay reachable. Sixty-six
  assertions in total, covering: propose; proposal merge / decline /
  withdraw; branch promote-to-branch / start-edit-branch / metadata /
  manual-flush / visibility / grants (POST + DELETE) / threads (POST)
  / messages (POST) / resolve / chat-seen / changes (accept / decline
  / reask) / chat-stream; super-draft start-edit-branch + metadata;
  PR pr-draft / open / seen / review / merge / withdraw /
  description / resolution-branch; discussion thread create + message
  post + resolve; admin role / mute / allowlist (POST + DELETE) plus
  the admin reads; notification preferences / quiet-hours / watch /
  mark-read / user-mute (POST + DELETE); funder credentials (POST +
  DELETE) + consent (POST + DELETE); graduation kickoff + claim +
  progress SSE; PR review page anonymous-readable.

### Anonymous-writeable allowlist

Two endpoints are intentionally anonymous-by-design. They are not
audit findings; they are documented here so the contract is
explicit:

- `GET  /auth/login` and `GET  /auth/callback` — the OAuth
  round-trip. Anonymous-by-design because they ARE the sign-in
  entrypoint.
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
  — anonymous in the session sense but authenticated by HMAC shared
  secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
  respectively). The webhook receiver is the wrong place for an
  authenticated session; the shared-secret shape is correct.

### §19.2 candidates surfaced

- None unique to this release. The two pre-existing candidates the
  audit touched — anonymous-read polish for the discussion surface
  (carried from v0.5.0) and the operator-visibility floor on
  graduation progress — were settled here as `require_user` on
  `/graduate/progress` rather than deferred.

### Upgrade steps (from 0.5.0)

1. The deployment **MUST** rebuild the frontend (`npm install &&
   npm run build`) so the frontend bundle's reported version matches
   the backend's. No new env vars; existing `frontend/.env` is
   sufficient.
2. The deployment **MUST** restart the backend so the new
   `require_user` gate on `/graduate/progress` is enforced. No
   schema migration runs.
3. The deployment **MAY** announce the audit completion to
   operators: every write-shaped backend endpoint now enforces an
   explicit gate, and the `test_anon_offlimits_vertical.py` test
   net asserts the contract on every CI run. The audited surfaces
   are listed in the `Added (tests)` section above and in `SPEC.md`
   §6.1.
4. The deployment **MUST NOT** assume any new envelope behaviour:
   v0.6.0 is purely a hardening release. No schema, no env, no
   dependency changes; the operator's role is reduced to rebuild +
   restart.


## 0.5.0 — 2026-05-27

**Minor — no operator action required.** This release wires the
PR-less per-RFC discussion surface (roadmap item #3, SPEC §10.10).
An RFC's main view now carries a discussion panel distinct from PR
comments and branch chat; contribution — proposing edits the document
will land — still requires opening a PR via the §10.1 affordance.
The substrate is the existing `threads` / `thread_messages` tables;
rows with `threads.branch_name IS NULL` scope to the RFC's main view.
No schema migration is required.

### Added

- **PR-less discussion endpoints** (`backend/app/api_discussion.py`)
  mounted at `/api/rfcs/<slug>/discussion/...`:
  - `GET  /api/rfcs/<slug>/discussion/threads` — list threads where
    `threads.branch_name IS NULL`. Anonymous-readable per the v0.3.0
    contract; the default whole-doc chat thread is materialized
    lazily on first read.
  - `POST /api/rfcs/<slug>/discussion/threads` — open a new
    discussion thread (`thread_kind='chat'`, `anchor_kind='whole-doc'`,
    `branch_name=NULL`). Body: optional `label`, optional first
    `message`. Requires contributor role.
  - `GET  /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
    — read messages on a discussion thread. Anonymous-readable.
  - `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
    — post a message. Body: `text`, optional `quote`. Requires
    contributor role.
  - `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve`
    — resolve a discussion thread. Permission per §10.10: thread
    creator, RFC owner / arbiter, or app admin / owner.
- **`RFCDiscussionPanel.jsx`** — the right-column surface on the RFC
  view when the viewer is on `main`. Composer requires sign-in;
  Cmd/Ctrl+Enter sends. Multiple threads surface as pill-shaped
  tabs above the message feed. A "New thread" affordance opens a
  fresh thread on the same RFC.
- **SPEC `§10.10` PR-less discussion vs. contribution** — settles
  the distinction between discussion (RFC-scoped, no PR, both
  anonymous-readable and contributor-writeable) and contribution
  (still requires a PR via §10.1). Also extends `§5`'s `threads`
  table commentary so the null-branch interpretation is documented
  as actively used rather than reserved scaffold.
- **SPEC `§17`** — lists the five new `discussion/...` endpoints
  in the illustrative table.

### Changed

- **`backend/app/chat.py`** — `_fan_out_chat` now passes
  `branch_name` straight through to the notify chokepoint instead of
  coercing `None` to `"main"`. The notifications row carries the
  null through, which preserves the §15.7 reconciler's keying on
  `(rfc_slug, branch_name)` for the eventual discussion-side
  chat-seen advance (deferred to a §19.2 candidate). Existing
  branch-scoped chat continues to pass non-null branch names; the
  change is invisible to that path.
- **`backend/app/notify.py`** — `fan_out_chat_message`'s
  `branch_name` parameter is now typed `str | None` to match the
  v0.5.0 PR-less shape. Routing rules are unchanged; the inbox prose
  renders identically whether the chat lives on a branch or on the
  RFC's discussion surface, which is the right honest signal.
- **`RFCView.jsx`** — the right-column panel is now conditional:
  when `branchParam === 'main'`, render `RFCDiscussionPanel`
  (the new PR-less surface); otherwise render the existing
  `ChatPanel` (branch chat unchanged).

### §19.2 candidates surfaced

- PR-less discussion: range / paragraph anchors (the data model
  permits them; the UI is the deferred part).
- PR-less discussion: distinct notification `event_kind`s
  (`open_rfc_discussion_thread`, etc.) if usage shows contributors
  want to filter discussion-vs-branch in the §15.2 inbox.
- PR-less discussion: chat-seen cursor closing the §15.7
  reconciliation loop for the new surface.
- PR-less discussion: AI participant invocation (discussion-only,
  no `<change>` block side-effects).
- PR-less discussion: anonymous-read polish to match the v0.6.0
  write-gate hardening (item #4).

### Upgrade steps (from 0.4.0)

1. The deployment **MUST** rebuild the frontend (`npm install &&
   npm run build`) so `RFCDiscussionPanel.jsx` ships in the bundle.
   No new env vars; existing `frontend/.env` is sufficient.
2. The deployment **MUST** restart the backend so the new
   `api_discussion` router mounts. No schema migration runs — the
   `threads` table already supports `branch_name IS NULL` per §5,
   and the v0.5.0 build is the first to write rows in that shape.
3. The deployment **MAY** announce the new surface to its
   contributors: the RFC view's main page now carries a discussion
   panel below the document. Existing branch chat and PR review
   surfaces are unchanged.
4. The deployment **SHOULD NOT** expect a hardening of the
   anonymous-write gate in v0.5.0 — that lands in v0.6.0 (item #4).
   v0.5.0's write paths already refuse anonymous posts, so no
   pre-emptive operator action is needed.

## 0.4.0 — 2026-05-27

**Minor — no operator action required beyond rebuild + restart.** The
proposer of a new RFC is now the implicit first owner of its super-
draft entry, set automatically at propose time from the session user.
The §13.1 claim flow remains available for *additional* owners. No
schema changes, no env-var changes, no API-shape changes; only newly
proposed RFCs receive the auto-owner — existing super-drafts whose
`owners:` is empty are unaffected and can still be claimed via §13.1
as before. This is a §19.3 rule-2 spec correction: `SPEC.md` §9.1,
§9.2, and §13.1 are updated to reflect the new shape.

### Changed

- **`POST /api/rfcs/propose`** (`backend/app/api.py`) now sets
  `Entry.owners = [user.gitea_login]` when constructing the new
  super-draft entry, instead of `owners=[]`. The session's
  `gitea_login` is the canonical source; the endpoint never accepted
  an owner field from the request payload and still doesn't.
- **`SPEC.md` §9.1** narrowed: the "no proposed-owner or working-
  group fields" sentence becomes a proposer-owner-auto / working-
  group-deferred split, with a §19.3 rule-2 note.
- **`SPEC.md` §9.2** frontmatter shape: `owners: []` → `owners:
  [<proposer.gitea_login>]`, with a §19.3 rule-2 note.
- **`SPEC.md` §13.1** reframed: claim flow is now a graduation-time
  broadening for additional owners, not a precondition for the
  proposer's own RFC. The §13.1 / §13.2 / §13.3 graduation pipeline
  itself is unchanged — the "at least one owner" precondition for
  graduation still holds and is now satisfied by default.

### Upgrade steps (from 0.3.0)

1. The deployment **MUST** rebuild and restart per the routine
   deploy steps. No `.env` changes, no schema/migration changes, no
   API-shape changes.
2. Operators **SHOULD** note that newly proposed RFCs after the
   upgrade carry the proposer in `owners:` automatically. Existing
   super-drafts with empty `owners:` are not migrated; they remain
   claimable via the §13.1 flow exactly as before. No deployment-
   side data action is required.
3. Deployments **MAY** communicate the UX shift to active proposers
   (the "Claim ownership" affordance no longer applies to your own
   newly proposed RFC), but the affordance simply hides on RFCs the
   viewer already owns, so no operator-side action is required.

## 0.3.0 — 2026-05-27

**Minor — operator action required if a deployment wants to enable the
private-beta gate; no action required to stay open.** This release adds
an email allowlist that, when populated, restricts OAuth sign-in to the
listed emails while keeping all read paths public. Anonymous visitors
now see the full app (catalog, RFC bodies, public branch conversations)
in read-only mode instead of the §14.1 landing-page wall.

### Added

- **`allowed_emails` table** (`backend/migrations/011_allowlist.sql`).
  Empty list = gate off (any successful OAuth provisions a user, as
  before). Any rows present = gate on (only listed emails, plus
  users already grandfathered by `gitea_id`, may sign in).
- **Admin → Allowlist tab** at `/admin/allowlist`. Add/remove emails,
  see who added each row and when. Status banner shows whether the
  gate is currently active.
- **`/beta-pending` page** shown after a rejected OAuth callback. Free-
  text invite-contact line is configurable via the new
  `VITE_BETA_CONTACT` env var (optional; falls back to a generic line).
- **Beta chips** next to the Discuss/Contribute mode toggle, the Sign
  in link, and the header Sign-in button so anonymous viewers see
  immediately what is gated.
- **Anonymous read mode** in the React app: the §14.1 Landing page is
  preserved at `/welcome` for deployments that want to link to it, but
  the default route now renders the full app shell with write
  affordances hidden behind a sign-in CTA.

### Changed

- **`/auth/callback`** now consults `auth.is_allowed_sign_in()` after
  fetching the Gitea profile. Rejected sign-ins clear the OAuth state
  and redirect to `/beta-pending`; the session is not populated.
- **`Catalog`** receives a `viewer` prop. Anonymous viewers see "Sign
  in to propose (Beta)" instead of "+ Propose New RFC".
- **`PhilosophyWithSidebar`** now reads `authenticated` from the
  current viewer instead of hardcoded `true`.

### Fixed

- **Single-finger scroll on the `/philosophy` page** (and any other
  `.chrome-pane`-hosted view: `/admin/*`, `/settings/notifications`)
  was broken on iOS Safari. The `.app` container used `height: 100vh`,
  which on iOS measures the URL-bar-hidden ("largest") viewport — so
  `.app` overflowed what's actually visible. Combined with the
  `body { overflow: hidden }` in `index.css`, this meant single-finger
  touches on the visible area were consumed by the (blocked) page-
  level scroll attempt rather than reaching the nested `.chrome-pane`
  scroll. Two-finger touches bypassed the page-level layer and
  one-finger then worked once the URL bar had collapsed. Switched
  `.app` to `height: 100dvh` (dynamic viewport — adjusts as the URL
  bar shows/hides), with `100vh` retained as a fallback for browsers
  predating iOS 15.4 / Chrome 108.

### Upgrade steps (from 0.2.3)

1. The deployment **MUST** rebuild the frontend with the new
   `VITE_BETA_CONTACT` env var optionally set in `frontend/.env` (it
   is OK to leave it blank — the `/beta-pending` page falls back to
   a generic line).
2. The deployment **MUST** restart the backend so migration
   `011_allowlist.sql` runs. No data loss; the new table starts
   empty, which keeps the gate off and preserves existing behavior.
3. To **enable** the private-beta gate, the deployment operator
   **SHOULD** sign in once (so their `users` row exists and they
   grandfather in by `gitea_id`), then open `/admin/allowlist` and
   add the first invited email. The first row added turns the gate
   on for any user not yet in `users`.
4. To **stay open**, do nothing — leave `allowed_emails` empty and
   the deployment behaves exactly as 0.2.3.
5. The deployment **MAY** customise its `/beta-pending` contact line
   by setting `VITE_BETA_CONTACT` (an email, a URL, or a short
   instruction) before the frontend build. Unset is fine.

## 0.2.3 — 2026-05-26

**Patch — no operator action required.** Rebuild and restart per the
routine deploy steps; no `.env` changes, no schema changes, no
behavior changes a deployment would notice in steady state beyond
the new endpoint below.

### Added

- **`GET /api/health`** — an unauthenticated probe returning JSON
  `{version, status}` for ops tooling. `version` is the running
  framework version (the contents of `VERSION` per §20.1, read at
  process start and cached as a module-level constant in
  `backend/app/health.py`); `status` is `"ok"` with HTTP 200 in
  v1. The `"degraded"` / HTTP 503 path stays reserved in the
  response shape so a later release can wire real degradation
  conditions without breaking the contract. The version-match
  check is the structural value — a deploy-control-panel polling
  the endpoint after `systemctl restart` catches the failure mode
  where a restart did not pick up the new code. See `SPEC.md` §17
  and the §19.2 settlement.

### Upgrade steps (from 0.2.2)

1. The deployment **MAY** configure its monitoring (Pingdom,
   Healthchecks.io, the flotilla deploy control panel, etc.) to
   probe `/api/health` and compare the returned `version` against
   the tag last deployed. The endpoint is unauthenticated by
   design — no PII in the payload, no session required.

## 0.2.2 — 2026-05-26

**Patch — no operator action required.** Rebuild the frontend and
restart per the routine deploy steps; no `.env` changes, no schema
changes, no behavior changes a deployment would notice in steady
state beyond the fix below.

### Fixed

- **Mermaid blocks rendered as raw code on `/philosophy`.**
  `frontend/src/components/Philosophy.jsx` parsed PHILOSOPHY.md with
  the bare `marked` import and `dangerouslySetInnerHTML`, so
  ```` ```mermaid ```` fences fell through as `<pre>` blocks rather
  than rendered diagrams. Swapped to `MarkdownPreview`, which already
  carries the lazy mermaid loader + SVG render path used by the RFC
  body view, so the philosophy surface now renders mermaid the same
  way RFC bodies do. Pre-existing gap, surfaced when a deployment
  authored a mermaid block in its `PHILOSOPHY_PATH` override.

## 0.2.1 — 2026-05-26

**Patch — no operator action required.** Rebuild the frontend and
restart per the routine deploy steps; no `.env` changes, no schema
changes, no behavior changes a deployment would notice in steady
state.

### Fixed

- **PR view blank page (React #310).** `frontend/src/components/PRView.jsx`
  declared its `threadsByKind` `useMemo` *after* the early-return
  guard for the loading state (`if (!pr) return …`). On first mount
  `pr` was null so the early return fired and 14 hooks were called;
  on the second render `pr` was populated and execution reached the
  `useMemo`, calling 15 hooks — violating the Rules of Hooks and
  unmounting the page subtree (blank screen). The `useMemo` is now
  declared above the early returns with optional-chaining on `pr`,
  keeping the hook count stable between the loading and loaded
  renders. Pre-existing bug in the post-Contribute-rewrite PR view;
  surfaced in production by 0.2.0's graduation merge race fix
  enabling the workflow that opens this code path.

## 0.2.0 — 2026-05-26

**Breaking config change.** The frontend now requires
`VITE_APP_NAME` to be set at build time. `npm run build` fails with a
clear message if it is missing. There is no default; every deployment
names itself.

### Upgrade steps (from 0.1.0)

1. The deployment **MUST** create a `frontend/.env` file before
   building. See `frontend/.env.example` for the contract.
2. The deployment **MUST** set `VITE_APP_NAME` in `frontend/.env` to
   the user-visible name it wants to ship — the string used as the
   browser tab title, the header brand, and the landing H1. The
   build will fail loudly if this is unset or blank.
3. The deployment **MUST** rebuild the frontend (`npm install && npm
   run build`) and redeploy the resulting bundle. The previous
   bundle does not read `VITE_APP_NAME`.
4. The deployment **SHOULD** verify the name appears correctly in
   the browser tab and the header after redeploy before bumping its
   `.rfc-app-version` pin to `0.2.0`.
5. The deployment **MAY**, in the same upgrade, take the opportunity
   to retire any local overrides it was using to brand the
   pre-0.2.0 hardcoded strings — those overrides are now obsolete.
6. The deployment **MUST NOT** continue to expect graduation step 4
   to fail on Gitea's "Please try again later" race; that path is
   now handled by `wait_for_mergeable` + bounded retry. Any local
   workaround retrying graduation at the deployment level is now
   redundant and **SHOULD** be removed.

### Fixed

- **Graduation merge race.** §13.3 step 4 (`merge_pr`) used to call
  Gitea's merge endpoint the instant step 3 returned, which races
  Gitea's background mergeability computation and produces the
  `405 "Please try again later"` response. Step 4 now waits for the
  `mergeable` field to become non-null (bounded by a 30s timeout) and
  retries the merge call up to three times on the transient response.
  See `backend/app/gitea.py#wait_for_mergeable` and
  `backend/app/bot.py#_merge_with_retry`.

### Changed

- `frontend/src/App.jsx` header brand reads `VITE_APP_NAME` instead of
  a hardcoded string.
- `frontend/src/components/Landing.jsx` H1 reads `VITE_APP_NAME`
  instead of a hardcoded string. The pitch / deck / attribution copy
  in this file is still deployment-specific and tracked as a follow-up
  for extraction into deployment config.
- `frontend/index.html` `<title>` is rewritten at build time via Vite's
  HTML transform.

### Added

- `frontend/.env.example` documenting the new required variable.
- Top-level `VERSION` file and this `CHANGELOG.md` as the canonical
  release log.
- `SPEC.md` §20 (versioning and downstream deployments) as the
  binding policy for the framework/deployment relationship.
- `docs/DEPLOYMENTS.md` as the practical guide for building on
  rfc-app and upgrading existing deployments to new versions.
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
  rule for working sessions.

