Files
rfc-app/CHANGELOG.md
T
Ben Stull 55d04ce4ca §22 S2: release v0.41.0 — create & navigate a second collection (@S2)
Minor, non-breaking: named collections via .collection.yaml, create-collection
endpoint, collection-scoped serve/propose, and the /p/<project>/ collection
directory. Completes acceptance @S2 (C3.6). 478 backend + 26 frontend green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:17:31 -07:00

3560 lines
187 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.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
AD) 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.0v0.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.0v0.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 (420 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 (420) 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 (420) 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.