Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
184 KiB
Changelog
The binding policy for what each kind of version bump means and what
the changelog has to carry is in SPEC.md §20. The
practical recipe downstream deployments follow when reading this file
is in 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
/ RFC 8174 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.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
(Part A model, Part E / §A.6 migration strategy). The structural model (Parts
A–D) is unaffected; the SPEC.md merge itself rides slice S6.
Added:
- Migration
029_collections.sql— (1) acollectionstable(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha)beneathprojects; (2) the per-corpus fields (type,initial_state) move down offprojectsonto 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_membersgeneralises intomemberships(scope_type ∈ {project, collection}, scope_id, user_id, role, …)with the role enum collapsed to{owner, contributor}(M2'sproject_admin→owner,project_contributor→contributor;project_viewerfolded intocontributorthis 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
projectsand 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_rfcrecovers a project by joiningcollections;auth.project_member_rolereadsmemberships;cache/api_*/funderwriters + readers key the 13 entry-corpus tables bycollection_id(the denormalisedproject_idtags oncached_prs/threads/changes/notifications/actions/pr_resolution_branchesare unchanged). Serving stays project-scoped (collection = default); the registry.collection.yamlreader 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.sqlautomatically); 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, ifDEFAULT_PROJECT_IDresolves to a non-defaultid and bootstrap-stamped rows still exist, it renamesproject_idfromdefaultto the configured id across every project-scoped table (discovered by column, so it stays correct as the schema grows) and drops the staledefaultprojectsrow (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 aforeign_key_checkbackstop 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 unsetDEFAULT_PROJECT_IDleavesdefaultin place. 450 backend green.
Upgrade steps:
- MAY set
DEFAULT_PROJECT_ID=<slug>in the backend overlay and add the matching project (sameid) toprojects.yaml. On the next deploy the re-stamp moves the original corpus onto<slug>once;defaultURLs never become public. Leave it unset to keep thedefaultid (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/proposestays as the default-project compat path. Slug uniqueness, the idea-PR reservation, the landing state (§22.4b), and theproposed_use_casesrow 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_pullsiterates every project'scontent_repo, stampingcached_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);ProposeModaltakes aprojectId;Appresolves the current project from the/p/<id>/URL so the propose modal targets it;Cataloglists 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) andGET /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_reponow iterates everyprojectsrow and mirrors each project'scontent_repointocached_rfcsstamped with thatproject_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+RFCViewpassuseProjectId(). The M3-frontendNotServedPlaceholderguard is removed — every registry project renders its corpus.ProjectLayoutkeeps 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_rfcsPK(slug)→(project_id, slug); theUNIQUE/PK oncached_branches,branch_visibility,branch_contribute_grants,stars,watches,pr_seen,branch_chat_seen,funder_consents,rfc_collaborators,contribution_requests,proposed_use_casesall gainproject_id; and the FKs tocached_rfcs(slug)onrfc_invitations,rfc_collaborators,contribution_requestsbecome composite(project_id, rfc_slug) → cached_rfcs(project_id, slug). (cached_prsis unchanged —(repo, pr_number)is already globally unique.) - Migration-runner capability (
db.run_migrations): a migration whose first line is-- migrate:no-foreign-keysruns withPRAGMA foreign_keystoggled OFF around it (required by SQLite's table-rebuild procedure, and a no-op inside a transaction) and aPRAGMA foreign_key_checkafter that fails the migration loudly on any dangling reference.
Changed:
- The
ON CONFLICT(...)upsert targets for the rebuilt tables gainproject_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 insertedproject_idstill defaults todefault, so behavior is identical for a single-project deployment.
Upgrade steps:
- MUST deploy. Migration
028runs automatically at startup; it rewrites the listed tables in one transaction with FK enforcement off and verifiesforeign_key_checkafter. No data is dropped (rows already carryproject_idfrom migration 026, M1). No config change. - 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) — bootsGET /api/deploymentonce and provides{ name, tagline, defaultProjectId, projects[], loading }to the tree. The neutralbrandTitle()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) fetchesGET /api/projects/:id, applies the project'sthemeas:rootCSS custom-property overrides (reset on switch/unmount so accents never bleed), providesProjectContext, and sets the tab title to the project name.- The §4 guard —
ProjectLayoutrenders 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/deploymentnow returnsdefault_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_NAMEis removed. The build no longer reads it and no longer fails without it; the deployment name comes from the registry (deployment.nameinprojects.yaml) served at runtime viaGET /api/deployment. The same build now serves any deployment. The build-time%VITE_APP_NAME%HTML token and theinject-app-nameVite plugin are gone;index.htmlships 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/:nare removed from the SPA and served as backend 308s instead — so nginx must route/rfc/and/proposals/to the backend rather than the SPAindex.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:
- MUST update nginx: add
location /rfc/andlocation /proposals/blocks thatproxy_passto the backend, before the SPAlocation /fallback. The framework'sdeploy/nginx/ohm.wiggleverse.org.conf(andtesting/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. - MUST ensure the registry
projects.yamldeployment.nameis set — it is now the header brand and tab title (already required since 0.33.0).taglineshows on the directory. - SHOULD remove
VITE_APP_NAMEfromfrontend/.env; it is no longer read (no error if left — simply ignored). - 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/healthis green. - MAY note: the Tier-1 Playwright e2e for these flows lands once the
Tier-1 Docker stack seeds a registry repo +
projects.yaml(REGISTRY_REPOis unset intesting/.env.tier1today, so the dockerized backend can't boot there post-0.33.0). This slice is covered by Vitest unit tests (DeploymentProvider,ProjectLayouttheme/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$PORTand reverse-proxying/api/,/auth/to a single-process uvicorn on127.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 readsprojects.yamlfromREGISTRY_REPOand mirrors itsdeployment:block andprojects:entries into theprojectstable + a newdeploymentsingleton. The mirror runs at startup (reconciler) and on every §4 webhook push to the registry repo. GET /api/deployment— returnsname,tagline, and the list of visible projects (each item carriesid,name,type,visibility). Replaces the build-timeVITE_APP_NAMEas the authoritative runtime config source (the frontend cut lands in M3-frontend).GET /api/projects/:id— returnsid,name,tagline,type,visibility,initial_state, andthemefor a single project.- §22.4b
initial_statehonored at propose time: new RFCs enter the state named by the project'sinitial_statefield (default:super-draft;bddprojects default toactive). - §22.4c
unreviewedflag: newly proposed RFCs are flaggedunreviewed = true. Owners clear it viaPOST /api/projects/:id/rfcs/:slug/mark-reviewed. The catalog accepts?unreviewed=trueto filter to the review queue. - Migration
027(additive): addsprojects.type/projects.initial_state, thedeploymenttable, andcached_rfcs.unreviewed/reviewed_at/reviewed_by.
Breaking:
META_REPOis retired. The app refuses to start ifREGISTRY_REPOis unset. The corpus mirror now reads each project'scontent_repofromprojects.yamlrather than from theMETA_REPOenv var.
Upgrade steps:
- MUST create a registry repo under your deployment's Gitea org.
- MUST author
projects.yamlat its root with adeployment:block (name,tagline) and oneprojects:entry for your existing corpus:Keepdeployment: name: <your deployment name> tagline: <your tagline> projects: - id: default name: <your project name> type: document content_repo: <your old META_REPO value> visibility: publicid: defaultfor this release; the pretty-slug re-stamp lands in the next backend slice, before any/p/URL is public. - MUST set
REGISTRY_REPO=<your registry repo name>and MUST removeMETA_REPO(or leave it unset — it is ignored but its presence may cause confusion). - MUST add a Gitea webhook on the registry repo pointing at
/api/webhooks/gitea(same secret as the corpus webhook). - MUST deploy. Migration
027runs automatically at startup; the registry mirror reconciles immediately after. VerifyGET /api/deploymentreturns your project and/api/healthis green. - SHOULD rebuild the frontend (no new env var is required until
M3-frontend lands, but the frontend currently still reads
VITE_APP_NAMEfor 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:
-
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 toactivewithid: nulland the slug remains the canonical identifier (§2.3).GET …/graduate/checktreats a blank id as valid (ok:true);POST …/graduateaccepts a blank/absentrfc_idand 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_idwas already nullable, so no data migration was needed for this part. -
Retire / un-retire — soft delete (§3, §3.1, §13.7). A fourth entry state,
retired, is added. An RFC's own owners (frontmatter) and siteowner-role holders — not app admins — may retire an entry (POST /api/rfcs/<slug>/retire); it flips toretiredvia 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 inrfcs/(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.sqlrebuildscached_rfcsto widen itsstateCHECK constraint to includeretired(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-linkrule is now a proper light-surface secondary button (white fill, hairline--c-gray-300border,--c-gray-700label, 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-linkscope, 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 nowflex-wraps 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-badgehad 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-quietnever 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-loginscope already carried is folded into the base rule, so everybtn-link-quietis now a true quiet link.
Users-tab cleanups, all token-based:
- Table column headers no longer wrap (
WRITE-MUTEDwas 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-200border and agray-50hover — 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-aboutet al.): borderless,gray-300icon 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>/graduatenow opens one meta-repo PR that re-serializes the entry withstate: active, the integerid, andgraduated_at/graduated_by— body unchanged,repoleft 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 thanstate == 'super-draft', so an active RFC's branches, body-edit PRs, threads, flags, andchangesall live on the meta repo exactly as a super-draft's do.promote-to-branchnames an active RFC's auto-branchedit-<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_repoand the per-RFC read path are dead for meta-only entries (the reconciler only sweeps entries with a non-nullrepo, 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.mdbody intorfcs/<slug>.md, setrepo: null(keepstate: activeand the integerid), and archive the per-RFC repo. An entry left with a non-nullrepokeeps using the retained legacy read path, but no new per-RFC repos are ever created. For OHM, RFC-0001humanwas 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-prsendpoint are retained (the field for schema stability + legacy entries; the endpoint as an informational, non-blocking surface), so no client contract is removed —graduate/checksimply no longer returns arepofield and never reportsblocking_prsas a gate.
0.30.2 — 2026-05-29
Patch — header nav label: the persistent chrome link reverts from
"Philosophy" back to "About." Display text only — the route
(/philosophy), the header-about class, and the page itself are
unchanged. A plain frontend rebuild applies it; no schema, API, config,
overlay, or secret change.
The §14.3 persistent link was relabeled "About" → "Philosophy" in
v0.21.0. This restores "About" as the neutral, framework-native label
(the CSS class header-about and the surrounding comment already call
it "the About link"). A deployment that wants a more pointed framing
can title its own About page in the PHILOSOPHY.md content the
PHILOSOPHY_PATH override serves.
Upgrade steps: none. SHOULD deploy as a normal code deploy.
0.30.1 — 2026-05-29
Patch — bug fix: a merged idea-PR whose branch was deleted no longer lingers as a phantom "pending idea." No schema, API, config, overlay, or secret change — a plain code deploy applies it, and the running reconciler clears any existing ghost on the next sweep (≤5 min) once deployed. Shipped from driver session 0040.0.
refresh_meta_pulls (and refresh_rfc_repo) recover a PR's slug/kind
by parsing its Gitea head.ref. When a PR is merged and its branch
deleted, Gitea stops reporting the real branch name and returns the
synthetic refs/pull/<N>/head sentinel instead. The slug then parsed
to None, the reconcile loop skipped the row, and cached_prs.state
stayed frozen at open forever — so the entry showed as both a
super-draft (the cached_rfcs push-event reconcile succeeded) and a
pending idea (the cached_prs PR-close reconcile never landed). The fix
recovers the original branch name from the already-stored cached_prs
row (which retains the real head_branch from when the PR was open;
migration 002) whenever Gitea reports an empty or refs/pull/ sentinel
ref. Regression test added in test_propose_vertical.py
(test_merged_idea_pr_with_deleted_branch_clears_proposal).
Surfaced through the ROADMAP #35 operator authoring lane, which merges
idea PRs from the CLI with branch-deletion enabled — a path the web UX
never exercises (it leaves branches in place, so
default_delete_branch_after_merge stays false). The framework should
not depend on branches outliving their merge, hence the framework-level
fix rather than a tooling workaround.
Upgrade steps: none. SHOULD deploy as a normal code deploy; the periodic reconciler self-heals any existing phantom on its next sweep.
0.30.0 — 2026-05-29
Minor — documentation: the user guide (DOCS.md, served at
/docs/user-guide via /api/docs) brought back in sync with the
shipped app. No code, schema, API, config, overlay, or secret change —
a plain code deploy serves the updated guide. Shipped from driver
session 0037.0.
The guide had drifted since it was first written: it still described the pre-OTC email allowlist sign-in, listed four propose-RFC fields, and predated several shipped surfaces. Updated to match v0.7.0–v0.29.0:
- Signing in rewritten for the email + one-time-code flow (v0.7.0),
optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
pending→ admin-grantedgate (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
pendingstate. - 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 (ProposeModalgained aninitialTitle; the affordance routes via?propose=<term>, read inApp.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 HaikuANTHROPIC_API_KEYpathway) 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 (" is working on an RFC for ''"). 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 acontribution_requestsrow and one actionable §15 inbox notification per owner (new kindcontribution_request_on_pending_rfc, categorypersonal-direct— so it reuses the existingemail_personal_directpreference, 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 (acontributorrfc_invitationsrow + 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 incached_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:
- Deployments MUST apply the auto-run migration
024(additive: the newcontribution_requeststable; no existing table or row is touched). The standard deploy path runs pending migrations on start — no manual step beyond deploying the new code. - 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_envelopeaccepted abody_html=argument that built amultipart/alternativebody, 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: passingbody_htmlraisesNotImplementedError. 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_tokenwas a synchronous function issuing a blockinghttpx.postfrom inside the async/auth/otc/requesthandler, so a slow CloudFlare response stalled the single worker for up to the 10s timeout. It is nowasyncand awaits the call on anhttpx.AsyncClient(matching the codebase's existing async-httpx pattern), isolated behind a narrow_siteverify_postseam. The sole caller (main.py) nowawaits 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 anyinnerHTML/dangerouslySetInnerHTMLwrite:MarkdownPreview, bothProposalViewsinks (entry body +proposed_use_case), andEditor. A hook addsrel="noopener noreferrer"totarget=_blanklinks.markedno 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 (migration023_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.lookupreads 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
Secureby default (SESSION_COOKIE_SECURE, defaults on; a dev box on plain http sets itfalse). - M5 — bounce webhook fails closed. An unset
WEBHOOK_EMAIL_BOUNCE_SECRETnow disables/api/webhooks/email-bounce(503) instead of leaving it open; a dev opts back in withRFC_APP_INSECURE_BOUNCE_WEBHOOK=1. - L2/L3 per-IP cooldown + check-endpoint throttle. L4 systemd
sandbox knobs (
CapabilityBoundingSet=,ProtectKernel*,RestrictAddressFamilies,SystemCallFilter, …).
Upgrade steps:
- Migration — none manual;
023_otc_verify_lockout.sqlauto-applies at startup viadb.run_migrations. - 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.
- nginx + systemd (MUST apply out-of-band) — the deploy gesture does
not install
deploy/nginx/ohm.wiggleverse.org.confordeploy/systemd/rfc-app.service. After deploying the code, copy both to their system locations, thennginx -t && systemctl reload nginxandsystemctl daemon-reload && systemctl restart <unit>. (M2 headers and L4 sandboxing do not take effect until this is done.) - Bounce webhook (SHOULD) — bind
WEBHOOK_EMAIL_BOUNCE_SECRET(or setRFC_APP_INSECURE_BOUNCE_WEBHOOK=1for dev). Unset → the endpoint returns 503 (closed). No legitimate bounce source is wired today, so 503 is the safe default. - Session cookie (SHOULD, dev only) — a deployment served over plain
http MUST set
SESSION_COOKIE_SECURE=falseor 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 newLinkedTextfrontend component maps segments onto React text nodes and anchors — nodangerouslySetInnerHTML— so a comment author cannot inject markup through this path. Every enriched field keeps its rawtext/descriptionalongside 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_idtoken (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:
- 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
activestate — 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_MODELSchat-picker universe, via the newproviders.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 ownANTHROPIC_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.
ProposeModaldebounce-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:
-
You MUST bind
ANTHROPIC_API_KEYfor 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):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. -
You MAY tune the per-user rate limit via
TAG_SUGGEST_RATE_MAX(default 30) andTAG_SUGGEST_RATE_WINDOW_SECONDS(default 60). The defaults apply when unset. -
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.
- Server-side last-state. A new
user_session_statetable 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, andlast_updated_at. Per-user (not per-device), matching the "go to where I was last" intent. - Route-change capture. A new frontend hook (
frontend/src/lib/ useLastState.js) debounce-posts the current route + light state toPUT /api/me/last-statefor authenticated users only; anonymous sessions are a no-op. The handler upserts the user's row and no-ops whenresume_enabledis 0. - Sign-in redirect. The stored
last_route(+ decoded state +resume_enabled) is folded onto the existing/api/auth/mepayload (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 Amplitudeidentifyhaving fired (identifyReady), preserving identify-then-track ordering — the first event on the resumed route carries the user_id. - 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 inSPEC.md§6.8).
Migration 022_user_session_state.sql creates the table. No new
environment variables, overlay keys, or secrets.
Upgrade steps:
- Deployments MUST apply database migrations;
022_user_session_state.sqlruns automatically on next boot (the migration runner globsbackend/migrations/*.sqland applies any not yet recorded inschema_migrations). The migration is additive — a new table — and requires no data backfill. - 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.
- 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. - 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. - 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. - Persistence (deployment note). In this deployment the framework's
rfcs/ PR surfaces are the Gitea-backed cache tablescached_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 tableproposed_use_cases(keyed by(scope, pr_number)) that the propose/open endpoints write directly and the view endpoints read back. The literal nullableproposed_use_casecolumns named by the roadmap are also added tocached_rfcs/cached_prsfor 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:
- Deployments MUST apply database migrations;
021_proposed_use_case.sqlruns automatically on next boot (the migration runner globsbackend/migrations/*.sqland applies any not yet recorded inschema_migrations). The migration is additive — nullable cache columns plus a new table — and requires no data backfill. - 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.
-
Design-token foundation (#31). New
frontend/src/styles/tokens.cssestablishes the app's first coherent design system — semantic color palette, type scale, spacing scale, radius scale, elevation, and a motion vocabulary (withprefers-reduced-motionhonored) — as CSS custom properties, imported first inmain.jsx. Before this the app carried ~98 distinct hardcoded hex colors, font sizes across 16 unscaled values, and radii across 13.App.cssandindex.csswere 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-visiblering 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. -
Header: "About" → "Philosophy" (#24). The persistent header link now reads "Philosophy" (the route
/philosophyand its page already existed; only the label changed). -
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-scopedInbox.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). -
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.0driver 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 optionaltldrstring on the per-sessionsessions.jsonmanifest 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:
-
Specs on
/docs/specs/<name>— a new public surface alongside the user guide that renders the framework's spec corpus at runtime. Configured via theOHM_DOCS_SPECSenv var; the framework default carries OHM's two specs (rfc-app/SPEC.mdandohm-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/specsclient-side redirects to the first configured spec (or renders a "no specs configured" empty state if the deployment cleared the list). -
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.Nordinal (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. -
/docs/sessions/<NNNN>body-list removed — operator preference: navigation lives in the left nav, not in body content. The per-session page is now a session-overview card (title + transcript count + "select a transcript from the navigation" hint). Empty-state, not-found, and error paths preserved; only the inline transcript-link list is gone.
Upgrade steps:
MAY: flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS='<JSON array>' to override the configured spec set. Each entry is {"name": "<slug>", "title": "<human label>", "url": "<gitea raw URL>"}. Malformed JSON, a non-array root, or an entry that fails validation (missing fields, non-slug name) logs a warning and falls back to the framework default; deployment startup is never crashed by a bad value.
MAY: flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS_CONTENT_TTL_SEC=300 to tune the per-spec content cache TTL.
Note on the frontend/package-lock.json version drift fix: the lockfile's top-level and packages."" version fields drifted to 0.15.0 somewhere in the v0.16.0–v0.19.0 window and weren't caught. This release syncs them to 0.20.0 alongside frontend/package.json and VERSION. No dependency changes; only the version-string fields move.
0.19.0 — 2026-05-28
Roadmap item #30: docs nav with on-site sessions browser. Adds a left-side flyout nav on /docs/* and three new public surfaces — /docs/sessions/about (renders the session-history README), /docs/sessions/<NNNN> (per-session index), /docs/sessions/<NNNN>/<filename> (per-transcript view). Backend mediates the fetch from wiggleverse/ohm-session-history over gitea raw URLs with a small in-process TTL cache (60 s manifest, 5 min content; both env-tunable). Existing /docs content moves to /docs/user-guide; bare /docs redirects.
Upgrade steps:
MAY: flotilla overlay set ohm-rfc-app OHM_SESSION_HISTORY_RAW_BASE=<url> if the deployment points at a non-OHM transcript repo. Default in code matches OHM's wiggleverse/ohm-session-history.
MAY: flotilla overlay set ohm-rfc-app OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC=60 and OHM_DOCS_SESSIONS_CONTENT_TTL_SEC=300 to tune cache TTLs.
Note: this release depends on the parallel restructure of wiggleverse/ohm-session-history into per-session NNNN/ folders + README.md + sessions.json (driver session 0017.0, subsession 0017.2). If the repo is still flat at deploy time, /docs/sessions/about and the per-session pages return 404 and the route tree degrades to "About not yet published" — no JS crashes; the User Guide remains fully functional.
0.18.0 — 2026-05-28
Minor — schema migration required; one env var now mandatory; no
new secrets. This release lands the framework-side half of OHM
roadmap items #18 (Secure the SMTP relay + Gitea webhook) and #20
(Email deliverability). It is the framework counterpart to the
operator-side SMTP / DNS hardening covered in the
EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md companion doc.
The release is shipped in five atomic slices per the v0.18.0 proposal:
build_envelopeshared helper. A single place where every outboundEmailMessageis 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.- Migrated send paths.
email_otc.py,email_invite.py,email._deliver,email._send_bundle, anddigest.pynow build their envelopes through the helper. Per-RFC invite (v0.16.0) rides throughemail_invite.py's helper and picks up the change for free. The shared_SENTtest buffer also carries the constructedEmailMessageunderenvelope["message"]so tests can assert on the header surface directly. - Webhook handler tightening.
GITEA_WEBHOOK_SECRETis now mandatory at startup — the framework refuses to load_config() if it's empty, unless the operator explicitly opts into the dev-bypass withRFC_APP_INSECURE_WEBHOOKS=1. The/api/webhooks/giteareceiver 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. outbound_emailsaudit table + admin endpoint. Every send helper writes one row tooutbound_emailsbefore returning, capturing the send attempt regardless of outcome (status='sent' / 'failed' / 'deferred'). The new admin endpointGET /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.- Bounce correlation. The
POST /api/webhooks/email-bouncebody accepts a new optionalmessage_idfield; when supplied, the handler stamps status='bounced' on the matchingoutbound_emailsrow and returns the row id ascorrelated_id. The pre-existing hard-bounce ->email_opt_out_all = 1flow 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— thebuild_envelopehelper. Single source of truth for every outboundEmailMessage'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. CatchesRuntimeError(so pure-helper unit tests wheredb.init()was never called don't break) and any other exception (so the audit write never breaks a send).GET /api/admin/outbound-emailsinapi_admin.py— admin-only listing ofoutbound_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/unsubscribeinapi_notifications.py— RFC 8058 one-click receiver. Accepts the same?t=token as the GET handler; idempotent; returns 200 +{ok, category}on success.allsynthetic category for unsubscribe URLs. Used by the bundle + digest paths (which can't honor per-category opt-outs because they span multiple categories); flipsemail_opt_out_all = 1rather than a per-category column.
Changed
backend/app/email.py—EmailConfiggainsunsubscribe_mailto(env:EMAIL_UNSUBSCRIBE_MAILTO, default falls back toEMAIL_FROM)._deliverand_send_bundlebuild envelopes throughbuild_envelopeand threadkind+notification_idinto the audit write.backend/app/email_otc.py+email_invite.py— both callbuild_envelopeandrecord_outbound. OTC carries noList-Unsubscribe(recipient explicitly requested the code); invite carriesList-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_deliverwithkind='digest'- the new
all-category one-click unsubscribe.
- the new
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 incached_rfcs.backend/app/config.py—load_config()raises RuntimeError ifGITEA_WEBHOOK_SECRETis empty unlessRFC_APP_INSECURE_WEBHOOKS=1.backend/app/api_notifications.py— GET unsubscribe handler accepts theallcategory (setsemail_opt_out_all = 1). Bounce webhook body adds optionalmessage_idfield; response shape addscorrelated_idfield. (Tests that read the exact response shape — currently justtest_bounce_webhook_refuses_unsigned_when_secret_configuredin test_e2e_smoke.py — updated to assert on the new shape.)backend/tests/test_propose_vertical.py— the sharedtmp_envfixture binds a fakeGITEA_WEBHOOK_SECRETso all 252 pre-v0.18.0 tests boot cleanly under the new mandatory secret. Tests that want to exercise the dev-bypass path monkeypatchRFC_APP_INSECURE_WEBHOOKS=1explicitly.
Tests
- 15 new unit tests in
test_email_envelope.pyfor the helper. - 10 new integration tests across
test_otc_vertical,test_admin_create_user_invite_vertical, andtest_notifications_verticalcovering: OTC has no List-Unsubscribe; invite has mailto: only; notification has full one-click; POST one-click flips per-category;allflips global; respectsEMAIL_UNSUBSCRIBE_MAILTOoverride. - 7 new integration tests in
test_webhooks_vertical.pycovering 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.pycovering 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_SECRETis 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=1to 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_MAILTOto routeList-Unsubscribe: <mailto:…>opt-out courtesy mail to a humans-monitored mailbox distinct from the no-replyEMAIL_FROMsender. Default falls back toEMAIL_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#versionandVERSIONboth move to0.18.0. No new secrets (theoutbound_emailstable writes synchronously to the same SQLite file as every other write). - Operators SHOULD run a
mail-tester.comprobe 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_DAYSinbackend/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/usersbody 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
userstable changes. The brief floatedfirst_sign_in_at/last_seen_at IS NULLas the "(pending invite)" discriminator, but the existingusers.last_seen_atcolumn is NOT NULL with adatetime('now')default (migrations/001) and nofirst_sign_in_atcolumn exists. Rather than land a schema migration to introduce one, the discriminator is the existence of an active (not-claimed, not-expired) row inuser_invite_tokensjoined oninvited_user_id. The admin user-listing carries apending_invitefield populated via that join; on claim, the badge clears naturally as the invite row'sclaimed_atpopulates. - 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 theusersrow with the chosen role and writes theuser_invite_tokensrow + dispatches the invite email + writes apermission_eventsrow withevent_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/verifyand/auth/device-trust/startsince 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 }. Theneeds_passcodehint 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 thedevice_trust.pyshape: opaque-token issuance with bcrypt-at-rest, candidate-set walk on lookup, dataclass-bracketed outcomes (CreateOutcome/ClaimOutcome/PendingInviteRow). Carries theINVITE_TOKEN_TTL_DAYS = 7constant and theCUSTOM_MESSAGE_MAX_LENGTH = 500mirror of the API-side bound.backend/app/email_invite.py— sibling ofemail_otc.py. ReusesEmailConfig.from_env()for the SMTP plumbing + From identity; composes a separate template (subject "You're invited to by "; 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_SENTbuffer so backend tests can assert on the outbound shape.- Schema migration
019_user_invite_tokens.sql— newuser_invite_tokenstable (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 ontoken_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, callsPOST /api/invites/claimon submit, and routes onward (/settings/notifications#sign-inifneeds_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 callsPOST /api/admin/usersand refreshes the user listing. - Amplitude wiring (per
ohm-rfc/ROADMAP.md#21 Part C, shipped inline with v0.17.0):USER_INVITEDevent fires fromCreateUserInviteModalon successful invite-send with{ target_user_id, initial_role, custom_message_chars }— the OHMinvited_user_idreturned byPOST /api/admin/usersbecomes the dashboard's binding for the future Amplitude user record;custom_message_charsis a coarse signal of admin effort (0 = template-only, 1+ = personalized) and carries no PII.INVITE_CLAIMEDevent fires fromInviteClaim.jsxon successful claim with{ invited_by_admin_id, initial_role, needs_passcode, trust_device }— BUT the claim handler first callsidentify({ 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_invitefield is populated by the backend's join throughuser_invite_tokens). Clears automatically on claim as the invite row'sclaimed_atpopulates.
Changed
backend/app/api_admin.py— importsinvites+email_inviteEmailConfig; adds the two new endpoints alongside the existingset_role/set_permissionneighbors; extendslist_usersto join throughuser_invite_tokensand emit thepending_invitefield on each row. The newCreateUserInviteBodypydantic 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— importsinvites as invites_mod; adds theInviteClaimBodypydantic model alongsidePasscodeVerifyBody; mounts thePOST /api/invites/claimendpoint in the OAuth router so it can reuse the_set_device_trust_cookiehelper.frontend/src/api.js— exportscreateUserInvite(),listUserInvites(),claimInvite(). Same fetch shape as the rest of the v0.9.0 / v0.10.0 admin neighborhood.frontend/src/App.jsx— importsInviteClaim; registers the/invites/claimroute 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#versionandVERSIONboth move to0.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_tokenstable is empty post-migration, and the user-listing's newpending_invitefield 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_invitationscarries 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_collaboratorsis the accepted-invitation substrate — the compact (rfc, user, role) shape the write gate consults. Both tables are FK-cascaded againstcached_rfcsandusersper §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), andPOST /api/invitations/accept(redeem). The email reusesEmailConfig.from_env()and the_SENTbuffer 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_ownerreads the frontmatterowners_json.is_rfc_collaboratorreads the v0.16.0rfc_collaboratorstable.can_discuss_rfcandcan_contribute_to_rfcare 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_rfcis 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_SENTevent fires fromInvitationsModal.jsxon successful send with{ rfc_slug, role_in_rfc };INVITATION_ACCEPTEDevent fires fromAcceptInvitation.jsxon successful accept with the same shape — but the accept path first callsidentify({ 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— registersapi_invitations.make_router()alongside the existing routers.backend/app/api_discussion.py—POST .../discussion/threadsandPOST .../discussion/threads/{thread_id}/messagesnow compose the newauth.can_discuss_rfcpredicate after the existingrequire_contributorcheck. 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-branchandPOST .../start-edit-branchnow composeauth.can_contribute_to_rfc. Same shape: platform-granted but uninvited → 403.backend/app/api_prs.py—POST .../open-pralso composesauth.can_contribute_to_rfcso 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/userscarries a newrfc_invitationsarray 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/acceptroute (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 thegrant_rfc_collaboratortest 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 existingdb.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#versionandVERSIONboth move to0.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_ENABLEDvariables 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.sqlon 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_collaboratorsrows directly via SQL for pre-existing per-RFC working relationships you want to grandfather past the v0.16.0 cutover. Theinvitation_idcolumn 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(), theEVENTStaxonomy constant, and a__resetForTestshelper. Internally lazy-imports@amplitude/unifiedand callsamplitude.initAll(API_KEY, { analytics: { autocapture: true }, sessionReplay: { sampleRate: 1 } })only after consent is granted; queues pre-init calls and drains them on init resolve; flipssetOptOut(true)on a granted→denied consent change (stops both analytics events and session replay). The wrapper subscribes toonConsentChange()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 AmplitudeIdentifyevent with.set()semantics by default; values wrapped as['__setOnce__', value]apply with.setOnce()semantics (immutable after first write — for account-history markers likefirst_sign_in_at). The newsetUserProperties(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 fromApp.jsxon every route change withpath(location.pathname); the location hook owns the firing and dedupes by path.RFC Viewed— fires fromRFCView.jsxonce per slug load withrfc_slugandrfc_id.User Signed In— fires fromLogin.jsxwithmethod ∈ { 'otc', 'passcode', 'trust-device' }matching the three sign-in paths from v0.7.0 / v0.10.0 / v0.11.0.User Signed Out— fires fromApp.jsx's "Sign out" click, followed byanonymize()to clear the SDK's user binding before the hard nav to/auth/logout.RFC Proposed— fires fromProposeModal.jsxon submit success withrfc_slug.PR Opened— fires fromPRModal.jsxon submit success withrfc_slugandpr_number.Comment Posted— fires fromRFCDiscussionPanel.jsx(surface: 'discussion') and fromPRView.jsx(surface: 'pr', withpr_number) on each post-success.Beta Access Requested— fires fromLogin.jsxcapture-profile submit success. No PII in the event.Admin Permission Decision— fires fromAdmin.jsx's grant / revoke action withaction ∈ { 'grant', 'revoke' }andtarget_user_id(string).
- User binding + properties (
App.jsx): whenme.authenticatedlands and a user id is available, the wrapper'sidentify({ user_id, properties })is called withString(viewer.id)AND a durable property bag —role,permission_state,passcode_set,device_trusted(mutable; refresh each sign-in), plusfirst_sign_in_atandaccount_created_at(setOnce — immutable user-history markers). The sign-out gesture callsanonymize()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/unifieddependency added tofrontend/package.json(analytics + session replay in one install). Lockfile updated.VITE_AMPLITUDE_API_KEYdocumented infrontend/.env.examplewith the secret-vs-overlay binding caveat (see below).
Changed
frontend/src/App.jsx— addsuseLocationfor the route-change Page Viewed firing, alastUserIdRefmemo to callidentifyonce per signed-in viewer, and anonClickhandler on the "Sign out" link that firesUser Signed Out+anonymize()before the hard nav.frontend/src/components/Login.jsx— firesUser Signed Inwith the appropriatemethodat each of the three sign-in points (trust-device cookie path, passcode verify success, OTC verify success), and firesBeta Access Requestedon capture-profile submit success.frontend/src/components/ProposeModal.jsx— firesRFC Proposedwithrfc_slugon submit success.frontend/src/components/RFCView.jsx— firesRFC Viewedinside thegetRFCresolution so the event is keyed on the slug param and includes the loadedrfc_id.frontend/src/components/PRModal.jsx— firesPR Openedwithrfc_slugandpr_numberon submit success.frontend/src/components/RFCDiscussionPanel.jsx— firesComment Postedwithsurface: 'discussion'on send-success.frontend/src/components/PRView.jsx— firesComment Postedwithsurface: 'pr'andpr_numberon review-comment success.frontend/src/components/Admin.jsx— firesAdmin Permission Decisionon 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 installpicks up@amplitude/unifiedfrom the updatedfrontend/package.jsonand the refreshedpackage-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#versionandVERSIONboth move to0.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 asVITE_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_KEYunset 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.mdat 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 withCLAUDE.md's separation-of-concerns rule.backend/app/docs.py— sibling loader forphilosophy.py. ReadsDOCS.mdfrom the repo root with the same disk-first, in-process-cached,refresh()-on-demand shape. OptionalDOCS_PATHenv var points at an alternative source (e.g. a meta-repo working-tree clone) for deployments that prefer that.§17endpoint —GET /api/docsreturns{ "body": "<DOCS.md verbatim>" }. Anonymous-reachable, same contract asGET /api/philosophy.frontend/src/components/Docs.jsx— the/docsreading surface. MirrorsPhilosophy.jsx: chrome with Back / "USER GUIDE" / Home affordances, body rendered throughMarkdownPreview.
Changed
backend/app/api.py— importsdocs as docs_modalongsidephilosophyin the relative-import block; registers the newGET /api/docshandler immediately afterGET /api/philosophy.frontend/src/api.js— exportsgetDocs()alongsidegetPhilosophy(). Same fetch shape, different endpoint path.frontend/src/App.jsx— importsDocsalongsidePhilosophy, registers the/docsroute alongside/philosophy, adds the persistent "Docs" header link alongside "About", and adds theDocsWithSidebarchrome wrapper alongsidePhilosophyWithSidebar.
Upgrade steps (from 0.13.0)
- You MUST rebuild the frontend and restart the backend after
upgrading so the new
/docsroute, the new endpoint, and the new loader are picked up.frontend/package.json#versionandVERSIONboth move to0.14.0. No schema migration; the new endpoint serves a checked-in file. - You MAY set
DOCS_PATHto an absolute path if your deployment hostsDOCS.mdoutside the framework's repo (e.g. as a sync target from a content repo). Unset is supported — the framework'sDOCS.mdat the repo root is the default, mirroring howPHILOSOPHY_PATHworks for/api/philosophy. - You MAY customize
DOCS.mdfor your deployment if you want deployment-specific phrasing layered on top of the framework's guide. The file is a regular markdown source; standardvim/gitedits suffice. Framework upgrades that ship a newDOCS.mdwill 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). ExportsgetConsent(),hasChosen(),onConsentChange(cb),setConsent(),hydrateFromServer(),clearLocal(). Cross-tab sync via thestorageevent. 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/notificationsshowing the current consent choice, the recorded-at stamp, and a "Change" button that re-opens the banner via a custom DOM event. §17endpoints —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, stampsrecorded_atto now, acceptsessentialfor symmetry but always persists it as true.
- Schema migration
013_cookie_consent.sql— newcookie_consenttable keyed byuser_id, three flags (essential,analytics,other_cookies), andrecorded_at. (Renumbered from012_*during driver integration because v0.7.0 also added a012_otc.sqlmigration that landed in the integration order before this one.) - SPEC
§14.5Cookie / 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 theconsent.jshelper surface for downstream callers including item #13. - SPEC
§14.6Privacy and cookies policy pages — settles the/privacyand/cookiesroutes, the framework's stub content, and theVITE_PRIVACY_POLICY_URL/VITE_COOKIES_POLICY_URLoverride shape. - SPEC
§5— names thecookie_consenttable 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 varsVITE_PRIVACY_POLICY_URLandVITE_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/privacyand/cookiesroutes (anonymous-reachable), wires<CookieConsentBanner>into the global chrome, and listens for arfc-app:cookie-consent-reopencustom 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#versionandVERSIONboth move to0.13.0and 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 byuser_idwith three flag columns and arecorded_atstamp. 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_URLto an http(s) URL that points at your deployment's full privacy policy. The framework's/privacypage 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_URLto 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_consentrow 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. POSTssecret+response(+ optionalremoteip) tohttps://challenges.cloudflare.com/turnstile/v0/siteverifyand returns aVerifyOutcome(okboolean +reasonenum: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 fromimport.meta.env.VITE_TURNSTILE_SITE_KEY; renders nothing when the var is unset (the form still submits and the backend'sTURNSTILE_REQUIREDpolicy decides admission). Loads the CloudFlare script once per page on first widget mount. Cleans up the widget instance on unmount viaturnstile.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 viamonkeypatch.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—OtcRequestBodygrows an optionalturnstile_tokenfield. The/auth/otc/requesthandler callsturnstile.verify_tokenfirst, beforeotc.request_code, so a failed challenge spends no rate budget and produces no envelope. The handler mapsmisconfigured→ 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—requestOtcaccepts 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/requestclears 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— documentsCLOUDFLARE_TURNSTILE_SECRETandTURNSTILE_REQUIREDalongside the existing OTC tunables.frontend/.env.example— documentsVITE_TURNSTILE_SITE_KEYwith 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/loginwith no widget; even after the operator sets the secret, the backend would refuse every request asmissing-tokenonceTURNSTILE_REQUIRED=trueflips. - You MUST rebuild the frontend and restart the backend after
upgrading.
frontend/package.json#versionandVERSIONboth move to0.12.0. No schema migration; Turnstile siteverify is stateless. The site-key embed is build-time, so the rebuild after theflotilla overlay setis what actually wires the widget into the bundle the deploy serves. - You MAY
flotilla overlay set ohm-rfc-app TURNSTILE_REQUIRED trueonce 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 totruemakes 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 defaultfalseexists 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_trusttable (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 ofotc.pyandpasscode.py. Carriesissue(user_id, user_agent),lookup(raw_token),list_for_user(user_id),revoke(user_id, row_id), andrevoke_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.§17endpointsPOST /auth/device-trust/start— anonymous-reachable. Reads therfc_device_trustcookie; 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: boolfield (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/startso 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 aconfirm()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 thetrust_deviceflag and accept an injectedResponseso 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. TheResponseimport is added alongside the existing FastAPI re-exports.backend/app/api.py— importsdevice_trust as device_trust_modalongsideauth/db; mounts the three/api/auth/me/devices*endpoints immediately after/api/auth/me/beta-requestso the auth-shaped neighborhood stays clustered.frontend/src/api.js— exportsstartDeviceTrust(),listMyDevices(),revokeMyDevice(id),revokeAllMyDevices().verifyOtcandverifyPasscodeaccept 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#versionandVERSIONboth move to0.11.0and the newSet-Cookieshape requires the backend to be on the matching version. - You MUST serve the deployment over HTTPS. The
rfc_device_trustcookie is set withSecure=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 againsthttp://localhostis unaffected (no cookie is set, the OTC/passcode paths continue to work). - You MAY announce the new feature to your users. Existing signed-in sessions are unaffected — the device-trust cookie is opt-in on the next sign-in, and a user who never checks the box keeps the v0.10.0 behavior verbatim.
0.10.0 — 2026-05-28
Minor — schema migration required; new auth path is additive.
This release lands user-set passcodes after OTC (roadmap item #8,
SPEC §6.2). After a successful one-time-code sign-in, the user can
set a passcode (4–20 characters) and use email + passcode for
subsequent sign-ins. OTC remains the structural fallback: a
forgotten passcode is recovered by requesting a fresh code, and
five consecutive failed passcode verifies lock the passcode path
for 15 minutes (HTTP 423) while leaving the OTC path open. The
/login surface now consults a new GET /auth/passcode/check
endpoint after the email step to decide whether to render a
passcode input or an OTC code input; an "Use a code instead" link
on the passcode step lets the user fall back to OTC manually. The
/settings/notifications page grew a new "Sign-in" tab where the
user can set, change, or remove their passcode.
Upgrade steps (from 0.8.0)
- MUST restart the backend so migration
015_passcode.sqlruns. The migration adds four nullable columns to theuserstable:passcode_hash,passcode_set_at,passcode_failed_attempts(default 0),passcode_locked_until. Existing rows pass through withpasscode_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. - MUST rebuild the frontend so the v0.10.0
/loginflow and the new settings tab ship.frontend/package.json#versionandVERSIONboth move to0.10.0. - 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."
- MAY leave the §6.2 default lockout shape (5 attempts, 15-minute window) unchanged. v0.10.0 does not expose env tunables for these; raising or lowering them lives in §19.2 as a candidate.
Added
POST /auth/passcode/set— authenticated. Body{passcode}. Validates length (4–20) and refuses obvious patterns from a small denylist (0000,1234,aaaa,password, etc.). bcrypt-hashes the passcode and writesusers.passcode_hashplususers.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 withlocked_untilwhen 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 paramemail. 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 thepasscode_set_atstamp are not leaked. An unknown email and a known-without- passcode email both returnfalse, so the endpoint is account-enumeration-safe.- Schema migration
015_passcode.sql— four ALTER TABLE ADD COLUMN statements on theuserstable: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 recordedpasscode_set_atstamp when set, and Set / Change / Remove buttons. Mirrors the §14.5 "Privacy & cookies" tab pattern. - SPEC
§6/§14.1/§17/§19.2corrections 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
/loginsurface 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/mepayload now includeshas_passcode(boolean) andpasscode_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— addscheckPasscode,verifyPasscode,setPasscode,clearPasscodehelpers, neighboring the v0.7.0requestOtc/verifyOtcblock.frontend/src/components/NotificationSettings.jsx— adds theSignInSectioncomponent betweenMutesSectionandPrivacyCookiesSection.
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 persistpasscode_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_atupdates on every set; validation refuses too-short passcodes and denylist patterns;/api/auth/mecarrieshas_passcodeandpasscode_set_atcorrectly.
Environment variables
None new. The existing SECRET_KEY continues to sign session
cookies; passcode hashing reuses the bcrypt dependency added in
v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
range (4–20) are hard-coded in backend/app/passcode.py. See
§19.2 for the env-tunable candidate.
0.9.0 — 2026-05-28
Minor — no schema migration; reuses v0.8.0 columns and existing
SMTP. This release ships the admin user-management surface at
/admin/users and the new-beta-request notifications that feed
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
compose: admins receive an inbox + email signal the moment a
pending user submits the v0.8.0 capture form; clicking through
lands on the page where they Grant or Revoke access.
The page consumes the v0.8.0 schema columns
(permission_state, first_name, last_name,
beta_request_reason, permission_decided_by,
permission_decided_at) without adding new ones — migration
slot 016 stays reserved for a future release. The single new
write endpoint, POST /api/admin/users/<id>/permission,
replaces v0.8.0's documented manual UPDATE users SET permission_state='granted' gesture with an audited UI flip.
Admin notifications ride the existing §15 chokepoint — the
new_beta_request event_kind is added to the enum with
category admin-actionable, fan-out is to every owner / admin
minus the requester themselves, and the §15.4 email dispatch
only reaches recipients whose email_admin_actionable toggle
is on (the default for owners + admins). The event is the
framework's first non-RFC-scoped notification — rfc_slug is
NULL and the email deep-link points /admin/users instead of
/rfc/<slug>.
Decision on /admin/allowlist: the surface stays as a
sibling sub-tab, not folded into /admin/users. The two have
different keys (allowlist by email pre-sign-up, user list by
user_id post-sign-up) and a union row would be confusing
rather than clarifying. The allowlist's fast-path-bypass role
from v0.8.0 is unchanged; retiring the table outright is
deferred to a later session (see §19.2).
Upgrade steps (from 0.10.0)
- MAY rebuild the frontend. The build is the same shape
as v0.10.0; the lockfile pins to
0.9.0sonpm installinfrontend/updates it cleanly. No new env vars on the frontend; the existingVITE_APP_NAMErequirement carries over. - 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. - 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. - 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."
- 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/userssurfaces all of them with their captured profile. Granting from the UI stampspermission_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, stampspermission_decided_by+permission_decided_at, and writes apermission_eventsrow with event_kind in{permission_granted, permission_revoked, permission_repended}. Refuses 422 on self-flip (symmetric toset_mute/set_roleself-action refusals) and 422 on invalid state. Returns{ok, permission_state, changed}wherechanged=falseindicates a no-op (the requested state already matched).- Widened
GET /api/admin/usersresponse carryingpermission_state,first_name,last_name,beta_request_reason,created_at,permission_decided_at, plus joinedpermission_decided_by_login/permission_decided_by_display. Sort order surfacespendingrows first (the admin queue), thengranted, thenrevoked; within a bucket, owners precede admins precede contributors, with recency as the tiebreaker. new_beta_requestevent_kind in the §15.1 enum. Fired bynotify.fan_out_new_beta_requestfrom the first successfulPOST /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/userspage enhancements inAdmin.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, neighboringsetUserMuteandsetUserRole./beta-pendingcopy update inBetaPending.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; theadmin-actionablecategory mapping is wired; the/api/admin/userslisting carries the v0.8.0 columns withpendingrows 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 returnschanged=falsewithout writing an audit row.
Changed
backend/app/api.py— thePOST /api/auth/me/beta-requesthandler now callsnotify.fan_out_new_beta_requestafter 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— thelist_usersquery joins againstusers d ON d.id = u.permission_decided_byfor the deciding-admin handle. The newset_permissionendpoint lives alongsideset_role/set_mute.backend/app/notify.py— addsCATEGORY_ADMIN_ACTIONABLE, thefan_out_new_beta_requesthelper, and thenew_beta_requestarm inrender_summary.backend/app/email.py— the_EVENT_TO_CATEGORYmap carriesnew_beta_request → admin-actionable, and_deep_linkroutes framework-scoped admin signals to/admin/usersinstead of/rfc/<slug>.frontend/src/components/Admin.jsx— theUsersTabcomponent is rewritten with state filter chips, a per-rowUserRow+PermissionCelldecomposition, and auseMemo-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#versionand 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-directnotification (event_kindpermission_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 inpermission_eventsbut 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/allowlistsub-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)
- MUST restart the backend so migration
014_beta_access.sqlruns. The migration addspermission_state(default'granted', so existing rows pass through unaffected),first_name,last_name,beta_request_reason,permission_decided_by, andpermission_decided_atto theuserstable, plus an index onpermission_statefor the pending queue. The migration is ALTER-TABLE-based (no table rebuild) — every foreign key and existing row passes through untouched. - MUST rebuild the frontend. The
Login.jsxsurface now runs a conditional third step (the capture form) on fresh OTC sign-ins;BetaPending.jsxcarries the new "your request is in review" copy;App.jsxrenders a thin pending-access banner. The build embeds the new/api/auth/me/beta-requestclient call. - 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.
- 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:
The pending queue lives in
UPDATE users SET permission_state = 'granted', permission_decided_by = <admin_user_id>, permission_decided_at = datetime('now') WHERE email = '<approved>';SELECT * FROM users WHERE permission_state = 'pending' ORDER BY created_at. - MUST decide whether to drain the
allowed_emailstable. 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.
- 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— addspermission_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_atto theuserstable. Plusidx_users_permission_statefor 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 leavespermission_state='pending'. Refuses HTTP 409 for already-granted / revoked users; refuses HTTP 401 for anonymous callers.needs_profileflag on the/auth/otc/verifyresponse.trueiff the user ispermission_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_statefield on the/api/auth/meresponse, plusfirst_name,last_name,beta_request_reason, and the sameneeds_profileflag.- First-OTC profile capture step in
Login.jsx. Third step in the sign-in surface, gated by the verify response'sneeds_profileflag. /beta-pendingrepurpose inBetaPending.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-pendingitself). - SPEC
§6opening /§6.1/§6.2/§14.1/§17/§19.2corrections 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; theallowed_emailstable is still present in the schema.
Changed
backend/app/auth.py#require_contributorwidens its gate to refusepermission_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.SessionUsernow carriespermission_state(default'granted'for the dataclass-default fallback path).backend/app/otc.py#request_codedrops the allowlist check from the OTC request flow. TheRequestOutcomeshape loses the'allowlist'reason (replaced by'sent'/'cooldown'/'invalid').backend/app/otc.py#provision_or_link_usersetspermission_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 setspermission_state='granted'explicitly on a fresh row. The OAuth callback still consults theis_allowed_sign_inallowlist check (the legacy fallback path retains its v0.3.0 admission shape during the OAuth migration window).backend/tests/test_otc_vertical.py— thetest_otc_request_silently_drops_when_email_not_on_allowlisttest (asserted the v0.7.0 allowlist gate) is replaced bytest_otc_request_admits_emails_regardless_of_allowlist_populationwhich 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#versionand the lockfile mirror.
Deferred to later releases
- Admin user-management page at
/admin/users(item #7, v0.9.0) — replaces the manual DBUPDATEgesture. - Allowlist UI deprecation (also v0.9.0) — once the
admin user-management page lands, the
/admin/allowlistsurface and theallowed_emailstable 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)
- MUST restart the backend so migration
012_otc.sqlruns. The migration rebuilds theuserstable (SQLite cannot ALTER COLUMN); existing rows pass through unchanged, but the new schema relaxesgitea_id/gitea_loginto nullable (with partial unique indexes that ignore NULL) and adds a partial unique index onemail. A newotc_codestable is created. - 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). - 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."
- 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(default10) — how long a one-time code is valid after issuance. Re-requesting invalidates the prior code immediately regardless of TTL.OTC_REQUEST_COOLDOWN_SECONDS(default60) — per-email cooldown between successive/auth/otc/requestcalls. 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 theusersrow 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— createsotc_codesand rebuildsuserswith nullablegitea_id/gitea_loginplus a partial unique index onemail.backend/app/otc.py— the OTC request/verify state machine and theprovision_or_link_userlinker.backend/app/email_otc.py— outbound OTC mail composition. Reuses the SMTP plumbing fromemail.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 theWelcomecomponent's inline link now route to/logininstead of jumping straight to the Gitea OAuth dance.- SPEC
§6.1/§6.2/§14.1/§17/§19.2corrections 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 NULLgitea_idand NULLgitea_loginto0/""so theSessionUsershape 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— addsbcrypt>=4.2for OTC code hashing. Pure-Python wheels are available on every platform the deployment matrix targets;pip install -r backend/requirements.txtpicks it up.frontend/src/App.jsx— adds the/loginroute and replaces the header "Sign in"<a href="/auth/login">with<Link to="/login">. TheWelcomecomponent's inline sign-in link follows suit.frontend/src/components/Landing.jsx— the/welcomepage'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/callbackroute 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/progressnow callsauth.require_user(request)as its first line. Anonymous callers receive 401 instead of being able to subscribe to a graduation's SSE. The floor isrequire_user(notrequire_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— theGET /api/rfcs/<slug>/graduate/progressbullet now documents therequire_usergate 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/loginandGET /auth/callback— the OAuth round-trip. Anonymous-by-design because they ARE the sign-in entrypoint.POST /api/webhooks/giteaandPOST /api/webhooks/email-bounce— anonymous in the session sense but authenticated by HMAC shared secret (GITEA_WEBHOOK_SECRETandWEBHOOK_EMAIL_BOUNCE_SECRETrespectively). 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_useron/graduate/progressrather than deferred.
Upgrade steps (from 0.5.0)
- 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; existingfrontend/.envis sufficient. - The deployment MUST restart the backend so the new
require_usergate on/graduate/progressis enforced. No schema migration runs. - 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.pytest net asserts the contract on every CI run. The audited surfaces are listed in theAdded (tests)section above and inSPEC.md§6.1. - 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 wherethreads.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: optionallabel, optional firstmessage. 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, optionalquote. 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 onmain. 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.10PR-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'sthreadstable commentary so the null-branch interpretation is documented as actively used rather than reserved scaffold. - SPEC
§17— lists the five newdiscussion/...endpoints in the illustrative table.
Changed
backend/app/chat.py—_fan_out_chatnow passesbranch_namestraight through to the notify chokepoint instead of coercingNoneto"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'sbranch_nameparameter is now typedstr | Noneto 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: whenbranchParam === 'main', renderRFCDiscussionPanel(the new PR-less surface); otherwise render the existingChatPanel(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_kinds (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)
- The deployment MUST rebuild the frontend (
npm install && npm run build) soRFCDiscussionPanel.jsxships in the bundle. No new env vars; existingfrontend/.envis sufficient. - The deployment MUST restart the backend so the new
api_discussionrouter mounts. No schema migration runs — thethreadstable already supportsbranch_name IS NULLper §5, and the v0.5.0 build is the first to write rows in that shape. - 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.
- 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 setsEntry.owners = [user.gitea_login]when constructing the new super-draft entry, instead ofowners=[]. The session'sgitea_loginis 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)
- The deployment MUST rebuild and restart per the routine
deploy steps. No
.envchanges, no schema/migration changes, no API-shape changes. - Operators SHOULD note that newly proposed RFCs after the
upgrade carry the proposer in
owners:automatically. Existing super-drafts with emptyowners:are not migrated; they remain claimable via the §13.1 flow exactly as before. No deployment- side data action is required. - 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_emailstable (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 bygitea_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-pendingpage shown after a rejected OAuth callback. Free- text invite-contact line is configurable via the newVITE_BETA_CONTACTenv 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
/welcomefor 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/callbacknow consultsauth.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.Catalogreceives aviewerprop. Anonymous viewers see "Sign in to propose (Beta)" instead of "+ Propose New RFC".PhilosophyWithSidebarnow readsauthenticatedfrom the current viewer instead of hardcodedtrue.
Fixed
- Single-finger scroll on the
/philosophypage (and any other.chrome-pane-hosted view:/admin/*,/settings/notifications) was broken on iOS Safari. The.appcontainer usedheight: 100vh, which on iOS measures the URL-bar-hidden ("largest") viewport — so.appoverflowed what's actually visible. Combined with thebody { overflow: hidden }inindex.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-panescroll. Two-finger touches bypassed the page-level layer and one-finger then worked once the URL bar had collapsed. Switched.apptoheight: 100dvh(dynamic viewport — adjusts as the URL bar shows/hides), with100vhretained as a fallback for browsers predating iOS 15.4 / Chrome 108.
Upgrade steps (from 0.2.3)
- The deployment MUST rebuild the frontend with the new
VITE_BETA_CONTACTenv var optionally set infrontend/.env(it is OK to leave it blank — the/beta-pendingpage falls back to a generic line). - The deployment MUST restart the backend so migration
011_allowlist.sqlruns. No data loss; the new table starts empty, which keeps the gate off and preserves existing behavior. - To enable the private-beta gate, the deployment operator
SHOULD sign in once (so their
usersrow exists and they grandfather in bygitea_id), then open/admin/allowlistand add the first invited email. The first row added turns the gate on for any user not yet inusers. - To stay open, do nothing — leave
allowed_emailsempty and the deployment behaves exactly as 0.2.3. - The deployment MAY customise its
/beta-pendingcontact line by settingVITE_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.versionis the running framework version (the contents ofVERSIONper §20.1, read at process start and cached as a module-level constant inbackend/app/health.py);statusis"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 aftersystemctl restartcatches the failure mode where a restart did not pick up the new code. SeeSPEC.md§17 and the §19.2 settlement.
Upgrade steps (from 0.2.2)
- The deployment MAY configure its monitoring (Pingdom,
Healthchecks.io, the flotilla deploy control panel, etc.) to
probe
/api/healthand compare the returnedversionagainst 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.jsxparsed PHILOSOPHY.md with the baremarkedimport anddangerouslySetInnerHTML, so```mermaidfences fell through as<pre>blocks rather than rendered diagrams. Swapped toMarkdownPreview, 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 itsPHILOSOPHY_PATHoverride.
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.jsxdeclared itsthreadsByKinduseMemoafter the early-return guard for the loading state (if (!pr) return …). On first mountprwas null so the early return fired and 14 hooks were called; on the second renderprwas populated and execution reached theuseMemo, calling 15 hooks — violating the Rules of Hooks and unmounting the page subtree (blank screen). TheuseMemois now declared above the early returns with optional-chaining onpr, 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)
- The deployment MUST create a
frontend/.envfile before building. Seefrontend/.env.examplefor the contract. - The deployment MUST set
VITE_APP_NAMEinfrontend/.envto 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. - The deployment MUST rebuild the frontend (
npm install && npm run build) and redeploy the resulting bundle. The previous bundle does not readVITE_APP_NAME. - The deployment SHOULD verify the name appears correctly in
the browser tab and the header after redeploy before bumping its
.rfc-app-versionpin to0.2.0. - 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.
- 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 the405 "Please try again later"response. Step 4 now waits for themergeablefield to become non-null (bounded by a 30s timeout) and retries the merge call up to three times on the transient response. Seebackend/app/gitea.py#wait_for_mergeableandbackend/app/bot.py#_merge_with_retry.
Changed
frontend/src/App.jsxheader brand readsVITE_APP_NAMEinstead of a hardcoded string.frontend/src/components/Landing.jsxH1 readsVITE_APP_NAMEinstead 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.exampledocumenting the new required variable.- Top-level
VERSIONfile and thisCHANGELOG.mdas the canonical release log. SPEC.md§20 (versioning and downstream deployments) as the binding policy for the framework/deployment relationship.docs/DEPLOYMENTS.mdas the practical guide for building on rfc-app and upgrading existing deployments to new versions.CLAUDE.mdat the repo root capturing the separation-of-concerns rule for working sessions.