Files
rfc-app/CHANGELOG.md
T
Ben Stull ee4925b6ac Release 0.16.0: owner-only invite for per-RFC contribution + discussion (+ #21 Part C Amplitude wiring)
Wave 5 / Track B. Roadmap item #12. Folds in #21 Part C Amplitude
wiring inline (operator ask: "best practices from the very get-go").

RFC owners can invite specific users to one of two per-RFC roles:
contributor (open PRs + join discussion) or discussant (discussion
only). Non-invited users keep the v0.6.0 anonymous-read contract.
The per-RFC write gate layers on top of the existing
require_contributor gate; a super-draft with no owners yet falls
through to the platform-granted contract, preserving the
v0.6.0/v0.7.0/v0.8.0 contracts in their domains.

Backend: migration 018_rfc_invitations.sql (auto-applied — two
tables: rfc_invitations + rfc_collaborators); api_invitations.py
with five endpoints + transactional email; auth.py helpers
(is_rfc_owner / is_rfc_collaborator / can_discuss_rfc /
can_contribute_to_rfc / can_invite_to_rfc); api_discussion +
api_branches + api_prs gate composition; api_admin.py additive
rfc_invitations[] per user. 237 backend tests pass (18 new in
test_rfc_invitations_vertical.py).

Frontend: InvitationsModal.jsx (owner surface), AcceptInvitation.jsx
(/invitations/accept route), api.js helpers, RFCView.jsx
Invitations button, App.jsx route registration.

Amplitude wiring (inline, #21 Part C):
  - INVITATION_SENT from InvitationsModal { rfc_slug, role_in_rfc }
  - INVITATION_ACCEPTED from AcceptInvitation { rfc_slug, role_in_rfc }
  - identify() BEFORE the accept event with properties: invited_at
    (setOnce), last_invited_to_rfc, last_invite_role_in_rfc,
    claim_method: 'rfc-invite'
  - EVENTS taxonomy extended with INVITATION_SENT + INVITATION_ACCEPTED

No new secrets, no new overlay keys, no operator gesture beyond the
v0.15.0 overlay-set + restart. Frontend build verified green.

Subagent ν shipped the feature on feature/v0.16.0-owner-invite
(a51beec). Driver-side integration squash-merged into main,
hand-resolved VERSION + package.json + CHANGELOG (strict-descending
0.16.0 → 0.15.0), and added the inline Amplitude wiring.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 05:06:55 -07:00

98 KiB
Raw Permalink Blame History

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.16.0 — 2026-05-28

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

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

Added

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

Changed

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

Migration

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

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

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

0.15.0 — 2026-05-28

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

Added

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

Changed

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

Migration

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

Caveat — overlay binding for VITE_AMPLITUDE_API_KEY

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

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

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

Upgrade steps (from 0.14.0)

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

0.14.0 — 2026-05-28

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

Added

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

Changed

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

Upgrade steps (from 0.13.0)

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

0.13.0 — 2026-05-28

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

Added

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

Changed

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

Upgrade steps (from 0.7.0)

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

0.12.0 — 2026-05-28

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

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

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

No schema migration — Turnstile siteverify is stateless.

Added

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

Changed

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

Upgrade steps (from 0.10.0)

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

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

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

0.11.0 — 2026-05-28

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

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

Added

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

Changed

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

Upgrade steps (from 0.10.0)

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

0.10.0 — 2026-05-28

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

Upgrade steps (from 0.8.0)

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

Added

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

Changed

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

Tests

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

Environment variables

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

0.9.0 — 2026-05-28

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

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

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

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

Upgrade steps (from 0.10.0)

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

Added

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

Changed

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

Environment variables

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

Deferred to later releases

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

0.8.0 — 2026-05-28

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

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

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

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

Upgrade steps (from 0.13.0)

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

Added

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

Changed

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

Deferred to later releases

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

0.7.0 — 2026-05-28

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

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

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

Upgrade steps (from 0.6.0)

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

New environment variables (all optional with defaults)

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

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

Added

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

Changed

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

Deferred to later releases

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

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

0.6.0 — 2026-05-28

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

Changed

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

Added (tests)

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

Anonymous-writeable allowlist

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

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

§19.2 candidates surfaced

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

Upgrade steps (from 0.5.0)

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

0.5.0 — 2026-05-27

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

Added

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

Changed

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

§19.2 candidates surfaced

  • PR-less discussion: range / paragraph anchors (the data model permits them; the UI is the deferred part).
  • PR-less discussion: distinct notification event_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)

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

0.4.0 — 2026-05-27

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

Changed

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

Upgrade steps (from 0.3.0)

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

0.3.0 — 2026-05-27

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

Added

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

Changed

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

Fixed

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

Upgrade steps (from 0.2.3)

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

0.2.3 — 2026-05-26

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

Added

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

Upgrade steps (from 0.2.2)

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

0.2.2 — 2026-05-26

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

Fixed

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

0.2.1 — 2026-05-26

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

Fixed

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

0.2.0 — 2026-05-26

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

Upgrade steps (from 0.1.0)

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

Fixed

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

Changed

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

Added

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