0d2fdfacf2
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
4118 lines
220 KiB
Markdown
4118 lines
220 KiB
Markdown
# Changelog
|
||
|
||
The binding policy for what each kind of version bump means and what
|
||
the changelog has to carry is in [`SPEC.md` §20](./SPEC.md). The
|
||
practical recipe downstream deployments follow when reading this file
|
||
is in [`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md).
|
||
|
||
The canonical version is the `VERSION` file at the repo root;
|
||
`frontend/package.json#version` mirrors it. Deployments pin to a
|
||
specific framework version in their own `.rfc-app-version` file
|
||
(per §20.5).
|
||
|
||
While the framework is pre-1.0, minor-version bumps may introduce
|
||
breaking changes; the entry below each such bump documents the
|
||
upgrade steps a deployment must apply.
|
||
|
||
Upgrade-steps blocks use the [RFC 2119](https://www.rfc-editor.org/rfc/rfc2119)
|
||
/ [RFC 8174](https://www.rfc-editor.org/rfc/rfc8174) normative-
|
||
language convention per `SPEC.md` §20.4. **MUST** / **SHALL** steps
|
||
are required; **SHOULD** steps are recommended (the framework's
|
||
default and tested path); **MAY** steps are optional. Upgrades that
|
||
skip versions are the composition of each intervening adjacent
|
||
release's steps in order — no A-to-B path is pre-computed beyond
|
||
that.
|
||
|
||
## 0.50.0 — 2026-06-07
|
||
|
||
**Minor — single-entry metadata edit + sidecar-aware writes (§22.4a SLICE-4).**
|
||
An authorized user can now edit one entry's schema-defined metadata directly from
|
||
its detail view. A new endpoint
|
||
`POST /api/projects/{id}/collections/{cid}/rfcs/{slug}/meta` takes `{values:{…}}`,
|
||
validates them against the collection's `fields:` schema at the write boundary
|
||
(INV-4), writes them to the entry's `<slug>.meta.yaml` **sidecar** with a **direct
|
||
commit** (D7 — no PR for authorized roles), and re-ingests. A legacy entry is
|
||
**lazy-migrated** to a clean body-only `.md` + sidecar on its first metadata edit.
|
||
The detail view renders one control per declared field (enum→select, tags→chips,
|
||
text→input), read-only without contribute access; `GET` on an entry now returns
|
||
its `meta` mapping and a `can_edit_meta` capability. This completes PUC-1 of the
|
||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||
design (§7.2); bulk edit (SLICE-5) follows.
|
||
|
||
Carried from SLICE-1: **every entry write path is now sidecar-aware.** Graduation,
|
||
ownership claim, retire/un-retire, mark-reviewed, the body-edit accept/flush
|
||
wrappers, and the PR-replay wrappers all dual-read an entry (so a migrated
|
||
body-only `.md` no longer crashes `entry.parse`) and write metadata changes to the
|
||
sidecar, keeping the `.md` body pure (INV-2) and never re-growing frontmatter; a
|
||
legacy entry lazy-migrates on its first metadata-bearing write. With the write
|
||
paths safe, the **Owner-gated collection-migration endpoint**
|
||
`POST /api/projects/{id}/collections/{cid}/migrate` now ships (PUC-5): it converts
|
||
a collection's legacy-frontmatter entries to clean body-only `.md` + sidecars in
|
||
one commit, idempotently.
|
||
|
||
Non-breaking and opt-in (INV-5): a collection with **no `fields:`** exposes no
|
||
edit panel and the edit endpoint returns 422 ("no editable fields"); the §22.13
|
||
default `document` collection declares none — so **N=1 deployments see no change**.
|
||
|
||
**Upgrade steps**
|
||
|
||
- No schema migration. The edit and migrate endpoints are additive; the sidecar
|
||
storage layer (mig 033) and `meta_json` index (mig 034) shipped in 0.47.0/0.49.0.
|
||
- A deployment adopts single-entry editing by declaring an `enum`/`tags`/`text`
|
||
`fields:` block in a collection's `.collection.yaml` (SLICE-2). Contributors+
|
||
on that collection (§22 Part B / S3 scope roles) may then edit from the detail
|
||
view; the change is a direct commit to the entry's sidecar.
|
||
- Operators MAY run `POST …/collections/{cid}/migrate` (collection Owner only) to
|
||
convert a collection's existing entries to clean body-only docs + sidecars up
|
||
front. It is idempotent and safe to re-run; dual-read means an un-migrated
|
||
collection keeps working, and entries lazy-migrate on their first metadata edit
|
||
regardless. **No data migration is required.**
|
||
|
||
## 0.49.0 — 2026-06-07
|
||
|
||
**Minor — faceted left-pane filtering (§22.4a SLICE-3).** A collection that
|
||
declares a metadata field schema (`fields:`, SLICE-2) now gets a **faceted
|
||
catalog left pane**: one collapsible filter group per `enum`/`tags` field plus
|
||
state, each with per-value **result counts** and multi-select checkboxes, and a
|
||
"filter values…" search box on `tags` groups so they stay usable at many values.
|
||
Filters compose — OR within a field, AND across fields — and a "malformed
|
||
metadata only" toggle surfaces entries failing their schema (INV-3). This is
|
||
SLICE-3 of the
|
||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||
design (§7.2); the single-entry and bulk metadata-edit UIs (SLICE-4/5) follow.
|
||
|
||
The collection-scoped list endpoint
|
||
(`GET /api/projects/{id}/collections/{cid}/rfcs`, and the project-scoped
|
||
default-collection alias) now honours filter query params
|
||
(`?priority=P0&tags=checkout&state=active&malformed=true`), returns a
|
||
`facets: {field → {value → count}}` block with drill-down counts, and includes
|
||
each entry's metadata `meta` mapping; an unknown filter field is a 400. Migration
|
||
034 adds the additive `cached_rfcs.meta_json` column that persists per-entry
|
||
metadata values for the index.
|
||
|
||
Non-breaking and opt-in (INV-5): a collection with **no `fields:`** keeps the
|
||
existing state-chip catalog unchanged, and the §22.13 default `document`
|
||
collection declares none — so **N=1 deployments see no change**. The unscoped
|
||
cross-collection `GET /api/rfcs` is untouched.
|
||
|
||
**Upgrade steps**
|
||
|
||
- Migration 034 (`cached_rfcs.meta_json`) applies automatically on startup
|
||
(additive, nullable). It is populated lazily as the corpus reconciler /
|
||
content-repo webhooks re-ingest each collection; until an entry is re-ingested
|
||
its `meta_json` is NULL and it contributes no facet values. Operators wanting
|
||
facets populated immediately MAY trigger a corpus refresh (the reconciler sweep
|
||
on next startup does this). **No data migration is required.**
|
||
- A deployment opts a collection into faceting by declaring an `enum`/`tags`
|
||
`fields:` block in its `.collection.yaml` (SLICE-2). A collection with no
|
||
`fields:` keeps the existing state-chip catalog unchanged.
|
||
|
||
## 0.48.0 — 2026-06-07
|
||
|
||
**Minor — collection field schema + central validation (§22.4a SLICE-2).** A
|
||
collection can now declare a small **field schema** in its `.collection.yaml`
|
||
(`fields:` block), so its entries carry structured, typed metadata — `priority`,
|
||
`tags`, and any custom fields the deployment defines. The schema is mirrored into
|
||
the collection record and served on the collection API; a new central validator
|
||
checks each entry's stored values against it. This is SLICE-2 of the
|
||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||
design (§7.2), building on the SLICE-1 sidecars; faceted filtering (SLICE-3) and
|
||
the edit UIs (SLICE-4/5) follow.
|
||
|
||
Non-breaking and opt-in: a collection with **no `fields:`** behaves exactly as
|
||
before (INV-5), and the §22.13 default `document` collection declares none
|
||
(**N=1 sees no change**). No DB migration — the normalized schema rides in the
|
||
existing `collections.config_json` column.
|
||
|
||
Added:
|
||
|
||
- **`app/metadata_schema.py`** — the one place that knows a collection's field
|
||
shapes. `parse_fields(raw)` normalizes a `.collection.yaml` `fields:` block
|
||
**leniently** (INV-3): a bad block or a bad field def (non-mapping, unknown
|
||
type, `enum` without a non-empty `values:` list) is skipped with a warning,
|
||
never fatal — a typo in one field can't drop the whole collection from the
|
||
mirror. `validate(values, fields)` returns advisory `Problem`s (empty =
|
||
clean). v1 field types: **`enum`** (single value, controlled by a required
|
||
`values:`), **`tags`** (a list; free-form unless `values:` given), **`text`**
|
||
(a free string). `ref` / `multi-enum` are deferred (design §2). Keys an entry
|
||
carries that the schema does not declare ride along untouched, never flagged
|
||
(INV-7).
|
||
- **Registry ingest** (`registry.parse_collection_manifest`) reads the `fields:`
|
||
block into the collection config (→ `config_json`), beside `enabled_models`.
|
||
- **Collection read + API** (`collections.get_collection`) unpacks the schema
|
||
and `GET /api/projects/{id}/collections/{cid}` serves it as `fields` (`null`
|
||
when unset).
|
||
- **Advisory validation at ingest** — the corpus mirror
|
||
(`cache._refresh_collection_corpus`) validates each entry against its
|
||
collection's schema and flags a violation as `metadata_malformed` **without
|
||
blocking the read** (INV-3), OR-ed onto the SLICE-1 sidecar-syntax check.
|
||
Write-boundary **enforcement** (a 422 on the metadata-edit endpoints) lands
|
||
with those endpoints in SLICE-4/5.
|
||
|
||
### Upgrade steps (0.47.0 → 0.48.0)
|
||
|
||
- **No migration, no operator action.** A deployment opts in per collection by
|
||
adding a `fields:` block to that collection's `.collection.yaml`; until it
|
||
does, behavior is byte-for-byte unchanged.
|
||
- A collection's field schema is edited in git for v1 (in-app schema management
|
||
is deferred, design D8). After editing `.collection.yaml`, the registry mirror
|
||
picks the schema up on its next webhook / reconciler sweep.
|
||
- No config change. No content change is required.
|
||
|
||
## 0.47.0 — 2026-06-07
|
||
|
||
**Minor — metadata sidecars: storage + dual-read + migration tool (§22.4a
|
||
SLICE-1).** Entry metadata can now live in a per-entry `<slug>.meta.yaml`
|
||
**sidecar**, with the `.md` kept as pure prose (INV-2). The corpus mirror reads
|
||
the sidecar when present and falls back to legacy top-of-document frontmatter
|
||
otherwise (**dual-read**, INV-6), so existing corpora load byte-identically.
|
||
This is SLICE-1 of the
|
||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||
design (§7.2); the collection `fields:` schema + validation (SLICE-2), faceted
|
||
filtering (SLICE-3), and the edit UIs (SLICE-4/5) follow.
|
||
|
||
Non-breaking and opt-in: a collection with no sidecars behaves exactly as
|
||
before, and the §22.13 default collection is untouched (**N=1 sees no change**).
|
||
|
||
Added:
|
||
|
||
- **Dual-read parser** (`app/metadata.py`) — `read_entry(md, sidecar)` yields
|
||
identical records whether metadata comes from a sidecar or legacy
|
||
frontmatter (INV-6); a malformed sidecar never hard-fails a read — the entry
|
||
still loads and is flagged (INV-3). Unknown / forward-compat keys ride along
|
||
untouched through parse→serialize and the migration (INV-7;
|
||
`Entry.extra`).
|
||
- **Frontmatter→sidecar migration tool** (`metadata.migrate_collection`) — a
|
||
deterministic, idempotent tool that lifts a collection's legacy entries into
|
||
sidecars + body-only `.md`s in **one commit** (new Gitea `change_files`
|
||
batch); a fully-migrated collection is a no-op. The **operator trigger** for
|
||
it (an Owner-gated endpoint) is intentionally **deferred to SLICE-4**: the
|
||
propose/graduate/mark-reviewed/edit write paths still read `.md` frontmatter
|
||
directly, so they must become sidecar-aware before a corpus is migrated in
|
||
production. Until then the tool is shippable groundwork, not yet wired to a
|
||
production trigger (INV-8: the engine write paths are unchanged this slice).
|
||
- **Malformed-metadata flag** — migration `033_metadata_malformed.sql` adds
|
||
`cached_rfcs.metadata_malformed` (additive); the corpus mirror derives it and
|
||
the catalog + entry-detail APIs surface `metadata_malformed`. A degenerate
|
||
sidecar (malformed / empty / slug-less) never drops the entry — it loads with
|
||
its slug backstopped from the filename and is flagged (INV-3).
|
||
- **INV-7 at graduation** — graduation now carries an entry's unknown /
|
||
forward-compat frontmatter keys through the rebuild instead of dropping them.
|
||
|
||
### Upgrade steps (0.46.2 → 0.47.0)
|
||
|
||
- The framework **MUST** apply migration `033_metadata_malformed.sql` — it runs
|
||
automatically at startup (additive column, no rebuild, default `0`).
|
||
- **No operator action** otherwise. With no sidecars present (the default after
|
||
this upgrade) every corpus stays on the legacy frontmatter path, byte-for-byte
|
||
as before. The frontmatter→sidecar migration is **not** operator-triggerable
|
||
yet (its endpoint lands in SLICE-4); dual-read makes the storage change
|
||
invisible until then.
|
||
- No config change. No content change is required.
|
||
|
||
## 0.46.2 — 2026-06-07
|
||
|
||
**Patch — `SPEC.md` §22.4a contract amendment: entry metadata is
|
||
collection-configured, not type-driven (doc-only).** Reframes the binding
|
||
§22.4a "collection type" contract so a collection's entry **metadata schema**
|
||
is **collection-configured** — a `fields:` schema declared in
|
||
`.collection.yaml` plus per-entry `<slug>.meta.yaml` **sidecars** — rather than
|
||
a frontmatter schema hard-wired to the collection's `type`. Item 3's
|
||
**type-specific surfaces** (release planning for `specification`;
|
||
scenario/coverage views for `bdd`) are **deferred** to a future design; the
|
||
`bdd` coverage capability is recorded there as a future `ref`-field surface
|
||
rendered as hyperlinks (never fusing corpora across collections). What `type`
|
||
still selects is the entry-noun terminology (item 2, shipped v0.45.0) and the
|
||
default `initial_state` / review posture (§22.4b–c).
|
||
|
||
This is **SLICE-0** of the
|
||
[Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md)
|
||
design (§7.2) — the contract amendment that unblocks the build slices. It
|
||
supersedes the [per-type-surfaces](./docs/design/2026-06-06-per-type-surfaces.md)
|
||
draft (D11; banner already on that doc).
|
||
|
||
**No operator action; no schema or behavior change.** Per-type frontmatter
|
||
validation was never implemented — the engine treats every entry as markdown +
|
||
frontmatter regardless of type — so amending the contract changes no runtime
|
||
behavior, no migration, and no deployment config. Sidecar storage, the
|
||
collection `fields:` schema, validation, and the metadata UI arrive in later
|
||
slices (SLICE-1+).
|
||
|
||
Changed:
|
||
|
||
- **`SPEC.md` §22.4a** — reframed: `type` selects terminology + default
|
||
`initial_state`/review posture; entry metadata is collection-configured
|
||
(`fields:` + `<slug>.meta.yaml` sidecars); type surfaces deferred. The
|
||
`document`/`specification`/`bdd` bullets updated accordingly; a metadata
|
||
amendment blockquote records the supersession.
|
||
- **`SPEC.md` §2 and §22 forward-pointers** — the two amendment blockquotes
|
||
that said "entry frontmatter schema is type-dependent" now read
|
||
"collection-configured, not type-driven (§22.4a, as amended)".
|
||
|
||
## 0.46.1 — 2026-06-06
|
||
|
||
**Patch — migration 029 hardening for the §22.13 re-stamp aftermath.** Fixes a
|
||
crash deploying the three-tier series (v0.40.0+) onto a deployment that went
|
||
through the v0.39.0 `default`→`<id>` project re-stamp: the re-stamp updated
|
||
`cached_rfcs.project_id` but **not** the entry-satellite tables, leaving rows at
|
||
the stale `project_id` that migration 029's per-project collection backfill could
|
||
not map (`NOT NULL constraint failed: cached_branches__new.collection_id`), plus
|
||
stale rows that duplicate freshly-re-mirrored ones (`UNIQUE` collision) and stale
|
||
rows whose entry no longer exists. No operator action; **no schema change** — 029
|
||
gains a repair prologue only.
|
||
|
||
Fixed:
|
||
|
||
- **Migration 029 repair prologue** — before rekeying, each entry-satellite table
|
||
(`cached_branches`, `branch_visibility`, `stars`, `watches`, `pr_seen`, …) has
|
||
its `project_id` re-derived from its entry (`cached_rfcs`, by slug); rows whose
|
||
entry no longer exists are dropped (stale cache, rebuildable from gitea), and
|
||
stale rows that duplicate an already-correctly-stamped row are dropped (keeping
|
||
the fresh copy). A no-op on a clean/fresh deployment (empty or already-
|
||
consistent satellites), so fresh installs are unaffected — the existing 029
|
||
test suite passes unchanged, plus a new regression test for the stale/dup/
|
||
orphan shape.
|
||
|
||
No upgrade steps: applying 029 (now repaired) is automatic on deploy; the repair
|
||
only mutates the rebuildable `cached_*` caches.
|
||
|
||
## 0.46.0 — 2026-06-06
|
||
|
||
**Minor (non-breaking) — §22 three-tier refactor, slice S6 (remainder):
|
||
*request-to-join + the cross-collection inbox (§22.8).* S4 shipped the invite
|
||
half of joining a gated scope (an Owner grants a role directly); this ships the
|
||
other half — a user who knows a scope exists asks to join it, naming a desired
|
||
role, and the request is fanned out to that scope's Owners across the subtree
|
||
(the cross-collection inbox, §22.11), who accept (writing the `memberships` row)
|
||
or decline. Purely additive: one new table (no rebuild), one new endpoint group,
|
||
and inbox/affordance UI; no change to existing read or write semantics.**
|
||
|
||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||
(Part E slice S6) and `SPEC.md` §22.8 / §22.11. This closes the **request-to-join**
|
||
item flagged open at 0.45.0; the per-type *surfaces* (§22.4a items 1 & 3) remain
|
||
the last S6 item, still pending a discovery/spec pass.
|
||
|
||
Added:
|
||
|
||
- **Request-to-join a scope (§22.8)** — `join_requests` (migration 032), a
|
||
scope-grain analogue of `contribution_requests`: a `(scope_type ∈
|
||
{project,collection}, scope_id, requester, requested_role, message, status)`
|
||
row, one-open-per-`(scope, requester)`. New endpoints under
|
||
`/api/scopes/{scope_type}/{scope_id}/`: `GET join-target`, `POST
|
||
join-requests`, and the Owner's `POST .../{id}/accept` / `.../{id}/decline`.
|
||
Accept writes the membership via `memberships.grant` (the §22.8 "accepting
|
||
writes the membership row"); the request POST does **not** require the scope be
|
||
readable — that is how one joins a *gated* scope they were told about.
|
||
- **The cross-collection inbox (§22.11)** — a join request fans one actionable
|
||
§15 notification to every Owner whose reach covers the scope (collection
|
||
Owners + project Owners + global Owners + deployment owners/admins), so it
|
||
surfaces in the one deployment-wide inbox of anyone who can grant it. New
|
||
event kinds `join_request_on_scope` (owner-facing, Accept/Decline inline) and
|
||
`join_request_accepted` / `join_request_declined` (requester-facing).
|
||
- **Frontend** — a "Request to join" affordance in the project collection
|
||
directory and the per-collection catalog footer (shown when the viewer is
|
||
signed in, granted, and holds no role reaching the scope — the new
|
||
`viewer.can_request_join` flag), a `JoinRequestModal`, and the actionable
|
||
`JoinRequestRow` in the inbox.
|
||
- **`auth.effective_role_at_scope(user, scope_type, scope_id)`** — the
|
||
scope-grain twin of `effective_scope_role` (which keys on a collection),
|
||
folding global → project for a project target; drives the "already a member?"
|
||
gate and the `can_request_join` flag.
|
||
|
||
No upgrade steps: migration 032 is additive (a new table; no rebuild, no FK
|
||
changes to existing tables), and no env var or config changes are required.
|
||
|
||
## 0.45.0 — 2026-06-06
|
||
|
||
**Minor (non-breaking) — §22 three-tier refactor, slice S6: *the SPEC merge +
|
||
per-collection model universe + the type-driven entry noun.* The three-tier
|
||
model (deployment → project → RFC collection) is now written into the binding
|
||
`SPEC.md` as a canonical §22, so the spec finally reflects the shipped S1–S5
|
||
behavior; a collection may narrow its project's model universe; and the entry
|
||
noun ("RFC" / "Spec" / "Feature") follows the collection's type. Purely additive
|
||
over S5 — one additive column (no rebuild), docs, and two small features; no
|
||
change to existing read or write semantics.**
|
||
|
||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||
(Part E slice S6; Parts A/B/D, merged into `SPEC.md` §22). With S6 the §22
|
||
three-tier model is realized in the binding contract. **Two S6 items remain
|
||
open and are carried to a follow-up slice** (they want a discovery/spec pass
|
||
first, lacking BDD scenarios in Part C): the per-type *surfaces* — per-type
|
||
frontmatter schemas, the `specification` release-planning data model, the `bdd`
|
||
scenario/coverage views (§22.4a items 1 & 3, flagged "first proposals" in the
|
||
design doc) — and **request-to-join + the cross-collection inbox** (§22.8; S4
|
||
shipped the invite half).
|
||
|
||
Added:
|
||
|
||
- **§22 merged into `SPEC.md` (the keystone)** — a canonical §22.1–§22.14 writes
|
||
the three-tier model into the binding spec: the tiers + collection-grain
|
||
isolation, the registry (`projects.yaml`) + `.collection.yaml` manifests,
|
||
one-content-repo-per-project, per-collection slug identity, collection
|
||
`type`/`initial_state`/`unreviewed`, two-tier (narrow-only) visibility, the
|
||
unified `{owner, contributor}` role vocabulary at `{global, project,
|
||
collection}`, the four-layer most-permissive union, discovery/joining,
|
||
runtime branding, `/p/<project>/c/<collection>/` routing, the one inbox, the
|
||
per-collection model universe, and the default-project+collection migration.
|
||
The **S3 keystone reinterpretation of §B.1/§B.3** lands here: a plain granted
|
||
account is a granted *account*, not a global write role; "global RFC
|
||
Contributor" is an explicit `scope_type='global'` grant; the implicit-public
|
||
write baseline is grandfathered onto the migration-seeded default collection
|
||
only. Forward-pointer amendment notes added at §1/§2/§5/§6. The registry +
|
||
manifest formats are documented for operators in
|
||
[`docs/DEPLOYMENTS.md`](./docs/DEPLOYMENTS.md).
|
||
- **Per-collection model universe (§22.12)** — a collection's `.collection.yaml`
|
||
may carry an `enabled_models` list that narrows its project's universe, which
|
||
in turn narrows the deployment `ENABLED_MODELS`. Resolution (extending
|
||
§6.6/§6.7) is `funder ∩ per-entry models ∩ collection ∩ project`, with the
|
||
operator providers as the ceiling — a collection can only narrow, never widen.
|
||
An absent list inherits the parent; an empty list opts the collection out of
|
||
AI. Surfaced as `enabled_models` on `GET /api/projects/:id/collections/:cid`.
|
||
- **Type-driven entry noun (§22.4a)** — the displayed noun for an entry follows
|
||
the collection type (`document` → "RFC", `specification` → "Spec", `bdd` →
|
||
"Feature"), defined once in the framework and read from the API
|
||
(`entry_noun` on the collection, directory, and project surfaces) rather than
|
||
hardcoded. The catalog's propose control and the propose modal name entries
|
||
accordingly.
|
||
|
||
Schema:
|
||
|
||
- **Migration `031_collection_enabled_models.sql`** — additive
|
||
`collections.config_json` (paralleling `projects.config_json`), holding the
|
||
per-collection `enabled_models`. No table rebuild; `NULL` means "inherit the
|
||
project's universe."
|
||
|
||
Upgrade steps (from 0.44.0):
|
||
|
||
1. Rebuild and redeploy as usual; the framework **SHALL** apply migration
|
||
`031_collection_enabled_models.sql` automatically on start (additive column,
|
||
no data movement, no operator action).
|
||
2. A deployment **MAY** now add an `enabled_models` list to any
|
||
`.collection.yaml` to narrow that collection's model universe, and **MAY**
|
||
create `specification`- or `bdd`-typed collections to get the corresponding
|
||
entry noun. Both are optional; absent them every collection behaves exactly
|
||
as before.
|
||
|
||
## 0.44.0 — 2026-06-06
|
||
|
||
**Minor (non-breaking) — §22 three-tier refactor, slice S5: *in-app
|
||
create-project + the global directory.* A global Owner can now stand up a new
|
||
project end-to-end from the UI — the bot provisions a Gitea content repo and
|
||
commits the project to `projects.yaml`, and the registry mirror picks it up — and
|
||
the deployment directory shows a role-aware empty state. Purely additive over S4:
|
||
one new endpoint and UI, no schema migration, and no change to existing read or
|
||
write semantics. Completes acceptance scenarios `@S5` (C3.1–C3.2: the
|
||
global-directory empty states).**
|
||
|
||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||
(Part E slice S5, Part C.3 scenarios C3.1–C3.2). With S5 the role-and-empty-state
|
||
focus of Parts B/C is complete; type modules, membership lifecycle, hardening,
|
||
and the SPEC merge remain S6.
|
||
|
||
Added:
|
||
|
||
- **In-app create-project (§22 S5 / §A.2)** — `POST /api/projects` (global-Owner
|
||
only) provisions a Gitea content repo under the deployment org (seeding a
|
||
`README.md` so `main` exists), commits a new project entry to the registry's
|
||
`projects.yaml`, then re-runs the registry mirror so the `projects` + default
|
||
`collections` rows flow from the registry (§22.2 keeps the registry the source
|
||
of truth). The bot remains the only Git writer (§1); the action is logged
|
||
(`create_project`) for the §6.5 trail. The body takes `project_id` (a slug, not
|
||
`default`), `name`, `type` (the initial/default collection's), optional
|
||
`visibility` (defaults to `public`), and an optional `content_repo` name
|
||
(defaults to `<id>-content`).
|
||
- **Create-project gate** — `auth.can_create_project`: "+ New project" is a
|
||
global-Owner action — a deployment owner/admin, or a holder of an explicit
|
||
`scope_type='global'` Owner grant. A project- or collection-scope grant, or a
|
||
global RFC Contributor, cannot create projects (that role creates collections,
|
||
not projects).
|
||
- **Deployment-directory capability + redirect signals** — `GET /api/deployment`
|
||
now carries a `viewer` block (`can_create_project`) and
|
||
`default_project_readable` (whether the N=1 land-in-corpus redirect target is
|
||
reachable by this viewer). The frontend bounces into the default project only
|
||
when it is readable; a gated default (C3.2) or an absent default (C3.1, a
|
||
deployment with no projects) falls through to the directory's empty state
|
||
instead of a 404.
|
||
- **Role-aware global directory (§22 C3.1–C3.2)** — a global Owner landing on an
|
||
empty deployment directory sees a "Create your first project" CTA (opening the
|
||
create-project modal: id, name, type, visibility, optional content-repo); a
|
||
non-owner sees "Nothing has been shared with you yet" and no create action. The
|
||
directory also gains an Owner-only "New project" control when it is non-empty.
|
||
|
||
Upgrade steps:
|
||
|
||
1. No operator action is required. S5 adds one endpoint and UI only — there is no
|
||
migration and no change to existing authorization outcomes. A deployment that
|
||
was declaring projects directly in `projects.yaml` keeps working unchanged;
|
||
the new UI is an additional, equivalent way to commit the same registry entry.
|
||
|
||
## 0.43.0 — 2026-06-06
|
||
|
||
**Minor (non-breaking) — §22 three-tier refactor, slice S4: *invitation
|
||
surfaces + role-aware empty states.* An Owner can now grant scope roles from the
|
||
UI, and each tier shows a role-appropriate empty state. Purely additive over S3:
|
||
new endpoints and UI, no schema migration, and no change to existing read or
|
||
write semantics. Completes acceptance scenarios `@S4` (C2.1–C2.7: invitation
|
||
reach, bounding, supersession, and the pending-account floor; C3.3–C3.5: the
|
||
create-first-collection and propose-first empty states).**
|
||
|
||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||
(Part E slice S4, Part C.2 and C.3). The in-app create-project flow and the
|
||
global-directory empty states (C3.1–C3.2) remain S5.
|
||
|
||
Added:
|
||
|
||
- **Scope-role invitation surface (§22 C.2)** — `POST
|
||
/api/projects/<id>/members` grants `{owner, contributor}` to an existing
|
||
account (looked up by email) at the project, or at one collection (with
|
||
`collection_id`). The grant writes a `memberships` row immediately and emits a
|
||
§15 personal-direct notification (`scope_role_granted`) naming the project and
|
||
role — a direct grant, not an accept round-trip. `GET` lists the project
|
||
subtree's grants (project-Owner view); `DELETE
|
||
/api/projects/<id>/members/<user_id>` (optionally `?collection_id=`) revokes.
|
||
- **Invitation gates** — `auth.can_invite_at_project` /
|
||
`can_invite_at_collection`: managing membership is an Owner capability bounded
|
||
by the inviter's reach. A project Owner grants at the project or any collection
|
||
within it; a collection Owner grants only at that collection (never the project
|
||
or globally); an RFC Contributor manages no membership.
|
||
- **Broader-scope-supersedes (§22 C.2.6)** — granting at a broader scope removes
|
||
the grantee's narrower rows that the new grant subsumes (same-or-lower role
|
||
rank within the subtree); a stronger child grant survives a weaker parent grant
|
||
(no negative override).
|
||
- **Pending-account floor (§22 C.2.7)** — a grant to a `pending` deployment
|
||
account is recorded but confers no write until the account is granted at the
|
||
deployment (the §6 admission floor in `effective_scope_role`).
|
||
- **Viewer capability flags** — `GET /api/projects/<id>/collections` carries a
|
||
`viewer` block (`can_create_collection`, `can_invite`, `role`); `GET
|
||
/api/projects/<id>/collections/<cid>` carries `viewer.can_contribute /
|
||
can_invite / role`. These drive the role-aware UI without a second round-trip.
|
||
- **Role-aware empty states (§22 C.3.3–C.3.5)** — a project Owner landing on an
|
||
empty project sees a "Create your first collection" CTA (opening the
|
||
create-collection modal — surfacing the S2 endpoint, previously UI-less); a
|
||
contributor without create rights sees the bare empty directory; a collection
|
||
contributor landing on an empty collection sees "Propose the first entry"
|
||
(anonymous readers keep the S2 sign-in prompt). The directory also gains an
|
||
Owner-only **Members** control opening the invitation modal.
|
||
|
||
Upgrade steps:
|
||
|
||
1. No operator action is required. S4 adds endpoints and UI only — there is no
|
||
migration and no change to existing authorization outcomes. A deployment that
|
||
was managing `memberships` rows directly (the S3 administrative path) keeps
|
||
working; the new UI is an additional way to write the same rows.
|
||
|
||
## 0.42.0 — 2026-06-05
|
||
|
||
**Minor (breaking — upgrade steps below) — §22 three-tier refactor, slice S3:
|
||
*scope-role enforcement + collection-grain visibility.* Authorization is now
|
||
resolved by the four-layer most-permissive union of §B.2 — global → project →
|
||
collection → per-entry — over the unified `{owner, contributor}` roles, with the
|
||
§22.5 visibility gate enforced at the **collection** grain. A collection can be
|
||
"hidden from public existence" (`gated`): invisible to anonymous viewers and
|
||
omitted from the project directory, yet readable and listed for any contributor
|
||
holding a scope role that reaches it (collection / project / global). A
|
||
collection's visibility may be set only as strict or stricter than its project's.
|
||
Completes acceptance scenarios `@S3` (C1.1–C1.8: role usage, inheritance, the
|
||
most-permissive union, and no-negative-override).**
|
||
|
||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||
(Part B roles, Part C.1 scenarios, Part E slice S3). The invitation UI and
|
||
role-keyed empty states remain S4; grants in S3 are applied administratively
|
||
(via the `memberships` table / an Owner-authorized create surface).
|
||
|
||
Added:
|
||
|
||
- **Four-layer scope-role resolver** — `effective_scope_role(user, collection)`
|
||
folds a deployment owner/admin (global Owner), an explicit global-scope grant,
|
||
a project-scope grant, and a collection-scope grant into the most-permissive
|
||
unified role over a collection. Owner outranks RFC Contributor; there is no
|
||
negative override (a child scope can never subtract a parent grant).
|
||
- **Global-scope grants** — migration 030 extends `memberships.scope_type` to
|
||
`{global, project, collection}`. A "global RFC Contributor" (writes in every
|
||
collection of every project, distinct from a deployment owner) is a
|
||
`scope_type='global'` row (sentinel `scope_id='*'`).
|
||
- **Collection-grain visibility enforcement** — `can_read_collection` /
|
||
`require_collection_readable` gate the collection-scoped read, entry, and
|
||
propose routes; the project collection-directory (`GET
|
||
/api/projects/<id>/collections`) is now viewer-aware (a hidden/gated collection
|
||
is listed only for a scope-role holder; `unlisted` stays omitted from
|
||
enumeration). The effective read gate is the stricter of the project's and the
|
||
collection's visibility.
|
||
- **Collection visibility strictness** — a collection may narrow but never widen
|
||
its project's visibility (`public` < `unlisted` < `gated`). Enforced at
|
||
create-collection (422 on a looser request) and clamped at the registry mirror.
|
||
- **create-collection authority widened (§B.1)** — `POST
|
||
/api/projects/<id>/collections` now admits a project-scope or global-scope
|
||
grant holder (Owner **or** RFC Contributor — the project-level "create a
|
||
collection" affordance), not only a deployment owner/admin. A collection-scope
|
||
grant cannot create sibling collections.
|
||
|
||
Changed (breaking):
|
||
|
||
- **Write standing now requires an explicit scope grant outside the default
|
||
collection.** The pre-three-tier implicit-on-public write baseline (a granted
|
||
deployment `contributor` may propose on any public project with no membership
|
||
row) is **narrowed to the migration-seeded `default` collection only** (the N=1
|
||
case, §22.13). On every *explicitly-created* collection — and on every project
|
||
beyond the default — proposing, discussing, branching, and contributing now
|
||
require an explicit `{owner, contributor}` grant at the collection, its
|
||
project, or global scope. Reads of public collections are unchanged.
|
||
- Entry-scoped authority checks (mark-reviewed, graduate, branch read/contribute,
|
||
PR/discussion/contribution moderation) are re-pointed from the project grain to
|
||
the entry's **collection** grain, so a collection Owner administers exactly
|
||
their collection's subtree and no more.
|
||
|
||
Upgrade steps:
|
||
|
||
1. The schema migration (`030_global_scope.sql`) runs automatically on deploy and
|
||
is backward-data-compatible — existing `memberships` rows are preserved. No
|
||
operator action is required for the migration itself.
|
||
2. **A single-collection (N=1) deployment needs no further action.** The `default`
|
||
collection keeps the implicit-on-public write baseline, so existing granted
|
||
contributors keep proposing exactly as before.
|
||
3. **A deployment that has created additional collections (S2) MUST grant scope
|
||
roles to its contributors.** Any contributor who should write in a non-default
|
||
collection (or in a second project) now needs an explicit grant: a
|
||
`memberships` row at `scope_type` `collection` (that collection), `project`
|
||
(its project — covers every collection within), or `global` (`scope_id='*'` —
|
||
every project). A deployment owner/admin is unaffected (global Owner by role).
|
||
4. To make a collection **hidden from the public**, set `visibility: gated` in its
|
||
`.collection.yaml` (or the project's `visibility` in `projects.yaml`); the
|
||
value MUST be as strict or stricter than the parent project's. The mirror
|
||
clamps a looser value and logs a warning.
|
||
|
||
## 0.41.0 — 2026-06-05
|
||
|
||
**Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a
|
||
second collection.* A deployment can now host more than one RFC collection per
|
||
project: a second collection is created, navigated, and proposed into beside the
|
||
default one. Purely additive — a single-collection deployment is unchanged (its
|
||
`/p/<project>/` still redirects into the sole collection, C3.7/C3.8). Completes
|
||
acceptance scenario `@S2` (C3.6: an anonymous reader of an empty public
|
||
collection sees an empty catalog with no propose action and a sign-in prompt).**
|
||
|
||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||
(Part E slice S2) and the slice plan
|
||
[`docs/design/plans/2026-06-05-s2-second-collection.md`](./docs/design/plans/2026-06-05-s2-second-collection.md).
|
||
Scoped {owner, contributor} roles at the collection axis remain S3; the
|
||
role-keyed create/propose-first empty states remain S4.
|
||
|
||
Added:
|
||
|
||
- **Named collections via `.collection.yaml`** — the registry mirror walks each
|
||
project's content repo and upserts a collection per
|
||
`<subfolder>/.collection.yaml` manifest (`type`, optional `visibility` /
|
||
`initial_state` / `name`; `type` immutable per §22.4a, visibility inherits the
|
||
project's when omitted). The default collection still flows from
|
||
`projects.yaml`.
|
||
- **create-collection** — `POST /api/projects/<id>/collections` (deployment
|
||
owner/admin). The bot commits a `.collection.yaml` to the content repo's
|
||
`main`, then the registry re-mirrors so the `collections` row appears (the
|
||
registry stays the source of truth, §22.2). New reads
|
||
`GET /api/projects/<id>/collections` and `…/collections/<cid>`.
|
||
- **Collection-scoped serve + propose** —
|
||
`GET /api/projects/<id>/collections/<cid>/rfcs[/<slug>]` and
|
||
`POST …/collections/<cid>/rfcs/propose`. A propose writes the entry under the
|
||
target collection's `<subfolder>/rfcs/`.
|
||
- **Collection directory at `/p/<project>/`** — lists the project's visible
|
||
collections, or redirects into the sole one when there is exactly one
|
||
(preserving the S1 single-collection UX). The catalog rail + entry views read
|
||
the active `/c/<collection>/` segment and scope to it.
|
||
|
||
Changed:
|
||
|
||
- **The corpus mirror is collection-grained** — `cache.refresh_meta_repo`
|
||
iterates each project's collections and reads each collection's
|
||
`<subfolder>/rfcs/`, keying `cached_rfcs` by `collection_id`. The default
|
||
collection keeps the shipped repo-root `rfcs/` path; N=1 serving is unchanged.
|
||
|
||
> ### Upgrade steps (0.40.0 → 0.41.0)
|
||
>
|
||
> - No required operator action — the slice is additive and the default-collection
|
||
> paths are unchanged. A deployment **MAY** deploy this version with no config
|
||
> change and keep running exactly as on 0.40.0.
|
||
> - To add a second collection, a deployment owner/admin **MAY** call
|
||
> `POST /api/projects/<id>/collections` (or commit a `<subfolder>/.collection.yaml`
|
||
> to the content repo directly); the registry mirror picks it up on the next
|
||
> refresh.
|
||
> - Deployments that pin the framework version **MUST** bump their version pin to
|
||
> `0.41.0`.
|
||
|
||
## 0.40.0 — 2026-06-05
|
||
|
||
**Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection*
|
||
grain. A deployment now hosts N projects, each owning one content repo and
|
||
holding N RFC *collections*, each collection a typed corpus. S1 inserts the
|
||
collection grain beneath today's project as the invisible default: the
|
||
deployment runs exactly as before, now with a real collection layer and one
|
||
extra `/c/<collection>/` URL segment. Per-corpus configuration (`type`,
|
||
`initial_state`), entry keys, and membership move to the collection grain;
|
||
no operator action is required beyond deploying.**
|
||
|
||
See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md)
|
||
(Part A model, Part E / §A.6 migration strategy). The structural model (Parts
|
||
A–D) is unaffected; the SPEC.md merge itself rides slice S6.
|
||
|
||
Added:
|
||
|
||
- **Migration `029_collections.sql`** — (1) a `collections` table
|
||
`(id, project_id, type, subfolder, initial_state, visibility, name,
|
||
registry_sha)` beneath `projects`; (2) the per-corpus fields (`type`,
|
||
`initial_state`) move **down** off `projects` onto the collection; (3) one
|
||
**default collection** per project (`id='default'` for the standard
|
||
single-project deployment, `subfolder` = repo root), inheriting the project's
|
||
type / initial_state / visibility; (4) the 13 entry-corpus tables re-key
|
||
`(project_id, slug)` → `(collection_id, slug)` via the migration-028 rebuild
|
||
pattern, each row mapped to its project's default collection; (5)
|
||
`project_members` generalises into **`memberships(scope_type ∈ {project,
|
||
collection}, scope_id, user_id, role, …)`** with the role enum collapsed to
|
||
`{owner, contributor}` (M2's `project_admin`→`owner`,
|
||
`project_contributor`→`contributor`; `project_viewer` folded into
|
||
`contributor` this pass — the read-only tier is deferred).
|
||
- **`app/collections.py`** — collection resolution helpers
|
||
(`default_collection_id`, `collection_type`, `collection_initial_state`,
|
||
`project_of_collection`).
|
||
- **`/c/<collection>/` URL segment** — the canonical entry route is now
|
||
`/p/<project>/c/<collection>/e/<slug>`. The project landing
|
||
`/p/<project>/` redirects into the project's single (default) collection
|
||
(C3.7); the deployment root `/` continues to redirect into the sole project
|
||
(C3.8).
|
||
|
||
Changed:
|
||
|
||
- **The registry mirror** writes a project's grouping-tier fields (name,
|
||
content_repo, visibility, config) to `projects` and the per-corpus fields
|
||
(`type`, `initial_state`) to its default collection; §22.4a type-immutability
|
||
is now enforced on the collection.
|
||
- **Backend threading** — `auth.project_of_rfc` recovers a project by joining
|
||
`collections`; `auth.project_member_role` reads `memberships`;
|
||
`cache`/`api_*`/`funder` writers + readers key the 13 entry-corpus tables by
|
||
`collection_id` (the denormalised `project_id` tags on
|
||
`cached_prs`/`threads`/`changes`/`notifications`/`actions`/`pr_resolution_branches`
|
||
are unchanged). Serving stays project-scoped (collection = default); the
|
||
registry `.collection.yaml` reader and collection-aware serving land in S2.
|
||
|
||
Breaking:
|
||
|
||
- **`/p/<project>/e/<slug>` URLs gain a `/c/<collection>/` segment.** The
|
||
shipped v0.35.0 `/p/<project>/e/<slug>` form is preserved by a client-side
|
||
redirect into the default collection; the pre-multi-project `/rfc/<slug>` and
|
||
`/proposals/<n>` server 308s now target `/p/<default>/c/<default>/…`.
|
||
|
||
> ### Upgrade steps (0.39.0 → 0.40.0)
|
||
>
|
||
> - A deployment **MUST** deploy this version with its migrations applied (the
|
||
> standard startup path runs `029_collections.sql` automatically); the
|
||
> migration seeds the default collection and re-keys existing entries with no
|
||
> data loss. No configuration change is required.
|
||
> - Operators **SHOULD** be aware that the canonical entry URL is now
|
||
> `/p/<project>/c/default/e/<slug>`. Existing `/p/<project>/e/<slug>`,
|
||
> `/rfc/<slug>`, and `/proposals/<n>` links keep working (client redirect /
|
||
> server 308). External systems that hardcoded the old form **SHOULD** be
|
||
> updated to the collection-scoped form at their convenience.
|
||
> - Deployments that pin the framework version **MUST** bump their version pin
|
||
> to `0.40.0`.
|
||
|
||
Deferred (later slices): creating + navigating a second collection and the
|
||
registry `.collection.yaml` reader (S2); the four-layer scope-role resolver and
|
||
the `viewer` read tier (S3); invitation surfaces (S4); in-app create-project
|
||
(S5); per-type surfaces, membership lifecycle, and the SPEC.md merge (S6).
|
||
|
||
## 0.39.0 — 2026-06-04
|
||
|
||
**Minor — §22.13 step 1: the default-project-id re-stamp. A deployment can
|
||
move its original corpus off the bootstrap `default` id onto a meaningful slug
|
||
(e.g. `ohm`) so it lands at `/p/<id>/` and `default` is never a public URL.
|
||
No-op unless `DEFAULT_PROJECT_ID` is set to a non-`default` value.**
|
||
|
||
Added:
|
||
|
||
- **`projects.restamp_default_project(config)`** — at startup, after the
|
||
registry mirror, if `DEFAULT_PROJECT_ID` resolves to a non-`default` id and
|
||
bootstrap-stamped rows still exist, it renames `project_id` from `default` to
|
||
the configured id across **every** project-scoped table (discovered by
|
||
column, so it stays correct as the schema grows) and drops the stale
|
||
`default` `projects` row (its data has moved to the configured row the
|
||
registry mirror created). The rename runs with FK enforcement off — parent
|
||
and child rows move together, so the composite FKs stay consistent — with a
|
||
`foreign_key_check` backstop before commit. Idempotent.
|
||
- **Tests:** `test_restamp_default_project.py` — data + composite-FK children
|
||
move to the new id, the stale row is dropped, FK integrity holds, the second
|
||
call is a no-op, and an unset `DEFAULT_PROJECT_ID` leaves `default` in place.
|
||
450 backend green.
|
||
|
||
Upgrade steps:
|
||
|
||
1. **MAY** set `DEFAULT_PROJECT_ID=<slug>` in the backend overlay and add the
|
||
matching project (same `id`) to `projects.yaml`. On the next deploy the
|
||
re-stamp moves the original corpus onto `<slug>` once; `default` URLs never
|
||
become public. Leave it unset to keep the `default` id (no change).
|
||
|
||
## 0.38.0 — 2026-06-04
|
||
|
||
**Minor — §22 M3-backend Plan B (write path, propose): a new entry can be
|
||
proposed *into a specific project*, landing in that project's content repo and
|
||
surfacing under that project's proposals. A non-default project is no longer
|
||
read-only. No upgrade steps; single-project deployments are unaffected.**
|
||
|
||
Added:
|
||
|
||
- **`POST /api/projects/{pid}/rfcs/propose`** — propose into a chosen project
|
||
(read-gated, then project-level contribute-gated, §22.6/§22.7). The propose
|
||
body is now a project-parameterized helper; the unscoped `/api/rfcs/propose`
|
||
stays as the default-project compat path. Slug uniqueness, the idea-PR
|
||
reservation, the landing state (§22.4b), and the `proposed_use_cases` row are
|
||
all scoped to the target project.
|
||
- **`GET /api/projects/{pid}/proposals`** — pending idea-PRs scoped to one
|
||
project.
|
||
- **Per-project PR mirror** (`app/cache.py`): `refresh_meta_pulls` iterates
|
||
every project's `content_repo`, stamping `cached_prs.project_id` (was: the
|
||
default project only). `projects.content_repo(pid)` helper added.
|
||
- **Tests:** `test_project_scoped_propose.py` — propose into a second project
|
||
lands in its content repo + shows only under its proposals (not the
|
||
default's); gated-project propose 404s a non-member. 447 backend green.
|
||
|
||
Changed:
|
||
|
||
- **Frontend:** `api.proposeRFC(projectId, …)` / `listProposals(projectId)`;
|
||
`ProposeModal` takes a `projectId`; `App` resolves the current project from
|
||
the `/p/<id>/` URL so the propose modal targets it; `Catalog` lists that
|
||
project's proposals.
|
||
|
||
Known limitation (next slice): the **edit** write flows — branch / PR /
|
||
graduation — and the **default-project-id re-stamp** (§22.13 step 1) are not
|
||
yet project-scoped (they still target the default project's content repo). Per
|
||
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3.
|
||
|
||
## 0.37.0 — 2026-06-04
|
||
|
||
**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
|
||
second project's corpus now renders under `/p/<id>/`, isolated by its own slug
|
||
namespace. No upgrade steps; single-project deployments are unaffected.**
|
||
|
||
This is the slice that makes "multiple projects" *observable*: M3-frontend
|
||
(v0.35.0) shipped the shell with a "not served" guard for any non-default
|
||
project; v0.36.0 rebuilt the keys so project #2 can exist; this serves project
|
||
#2's corpus.
|
||
|
||
Added:
|
||
|
||
- **Per-project read endpoints** (`app/api.py`): `GET /api/projects/{pid}/rfcs`
|
||
(catalog scoped to one project) and `GET /api/projects/{pid}/rfcs/{slug}`
|
||
(entry by `(project_id, slug)`), both behind the §22.5 read gate
|
||
(gated project 404s a non-member). The unscoped `/api/rfcs[/{slug}]` remain as
|
||
the default-project compat path.
|
||
- **Per-project corpus mirror** (`app/cache.py`): `refresh_meta_repo` now
|
||
iterates every `projects` row and mirrors each project's `content_repo` into
|
||
`cached_rfcs` stamped with that `project_id` (was: the default project only).
|
||
- **Tests:** `test_project_scoped_serving.py` — catalog scoping, entry
|
||
isolation (same slug under two projects resolves distinctly; a slug present
|
||
only in one 404s under the other), gated-project 404. 445 backend green.
|
||
|
||
Changed:
|
||
|
||
- **Frontend** reads the scoped routes: `api.listRFCs(projectId)` /
|
||
`getRFC(projectId, slug)`; `Catalog` + `RFCView` pass `useProjectId()`. The
|
||
**M3-frontend `NotServedPlaceholder` guard is removed** — every registry
|
||
project renders its corpus. `ProjectLayout` keeps the 404/not-readable branch.
|
||
|
||
Known limitation (next slice): the **write** path (propose / branch / PR /
|
||
graduate) and the **default-project-id re-stamp** (§22.13 step 1) are not yet
|
||
project-scoped — write affordances still target the default project, so a
|
||
non-default project is effectively read-only until Plan B's write slice. (No
|
||
live impact: no deployment runs a second project yet.) Per
|
||
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3 (write).
|
||
|
||
## 0.36.0 — 2026-06-04
|
||
|
||
**Minor — §22 M3-backend Plan B (1/2): the slug-keyed PK/UNIQUE rebuild that
|
||
activates project #2. No behavior change (deployments are still single-project
|
||
`default`); migration `028` runs automatically on deploy.**
|
||
|
||
This lands the table rebuilds migration 026 deliberately deferred "until a
|
||
second project exists" — folding `project_id` into the slug-keyed PRIMARY KEY /
|
||
UNIQUE constraints so two projects can hold the same slug without colliding.
|
||
Shipped separately from per-project RFC *serving* (Plan B 2/2) for migration
|
||
hygiene: the schema change deploys and is verified on its own, smaller blast
|
||
radius.
|
||
|
||
Added:
|
||
|
||
- **Migration `028_project_scoped_keys.sql`** — rebuilds 13 tables to composite
|
||
slug keys: `cached_rfcs` PK `(slug)` → `(project_id, slug)`; the `UNIQUE`/PK
|
||
on `cached_branches`, `branch_visibility`, `branch_contribute_grants`,
|
||
`stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`,
|
||
`rfc_collaborators`, `contribution_requests`, `proposed_use_cases` all gain
|
||
`project_id`; and the FKs **to** `cached_rfcs(slug)` on `rfc_invitations`,
|
||
`rfc_collaborators`, `contribution_requests` become composite
|
||
`(project_id, rfc_slug) → cached_rfcs(project_id, slug)`. (`cached_prs` is
|
||
unchanged — `(repo, pr_number)` is already globally unique.)
|
||
- **Migration-runner capability** (`db.run_migrations`): a migration whose
|
||
first line is `-- migrate:no-foreign-keys` runs with `PRAGMA foreign_keys`
|
||
toggled OFF around it (required by SQLite's table-rebuild procedure, and a
|
||
no-op inside a transaction) and a `PRAGMA foreign_key_check` after that fails
|
||
the migration loudly on any dangling reference.
|
||
|
||
Changed:
|
||
|
||
- The `ON CONFLICT(...)` upsert targets for the rebuilt tables gain `project_id`
|
||
(`cache.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`,
|
||
`api.py`, `funder.py`) so they continue to match the new composite indexes.
|
||
The inserted `project_id` still defaults to `default`, so behavior is
|
||
identical for a single-project deployment.
|
||
|
||
Upgrade steps:
|
||
|
||
1. **MUST** deploy. Migration `028` runs automatically at startup; it rewrites
|
||
the listed tables in one transaction with FK enforcement off and verifies
|
||
`foreign_key_check` after. No data is dropped (rows already carry
|
||
`project_id` from migration 026, M1). No config change.
|
||
2. **MAY** note: per-project RFC *serving* (path-scoped endpoints + the
|
||
default-id re-stamp + the frontend guard removal) is the next slice (Plan B
|
||
2/2), per `docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md`.
|
||
|
||
## 0.35.0 — 2026-06-04
|
||
|
||
**Minor (breaking) — §22 M3 frontend: `/p/<project>/` routing, runtime
|
||
branding (the `VITE_APP_NAME` hard cut), the deployment directory + project
|
||
switcher, and server-side 308 redirects off the old corpus-root URLs.
|
||
Completes the runtime-config cut 0.33.0 began.**
|
||
|
||
Added:
|
||
|
||
- **`DeploymentProvider`** (`src/context/DeploymentProvider.jsx`) — boots
|
||
`GET /api/deployment` once and provides `{ name, tagline,
|
||
defaultProjectId, projects[], loading }` to the tree. The neutral
|
||
`brandTitle()` fallback (`'RFC'`) paints during the pre-fetch frame.
|
||
- **`/p/:projectId/*` routing** with the generic `/e/<slug>` entry segment
|
||
(§22.10). **`ProjectLayout`** (`src/components/ProjectLayout.jsx`) fetches
|
||
`GET /api/projects/:id`, applies the project's `theme` as `:root` CSS
|
||
custom-property overrides (reset on switch/unmount so accents never bleed),
|
||
provides `ProjectContext`, and sets the tab title to the project name.
|
||
- **The §4 guard** — `ProjectLayout` renders the corpus only for the
|
||
corpus-served (default) project; any other id renders a "content not yet
|
||
served" placeholder (`NotServedPlaceholder`), so per-project serving (the
|
||
next backend slice, Plan B) can land without a wrong-content footgun.
|
||
- **Deployment directory** at `/` (`Directory.jsx`) — renders the
|
||
caller-visible projects as cards when **2+** are visible; the **N=1** case
|
||
redirects straight into the single project (`/p/<id>/`), preserving OHM's
|
||
"land in the corpus" UX. A **project switcher** (`ProjectSwitcher.jsx`)
|
||
rides deployment chrome when 2+ projects are visible.
|
||
- **Entry-noun terminology** (RFC / Spec / Feature) driven by `project.type`
|
||
(§22.4a); the route segment stays the generic `/e/`.
|
||
- **`GET /api/deployment`** now returns **`default_project_id`** — the
|
||
corpus-served project the frontend guard keys on.
|
||
- **Server-side 308 redirects** (`app/api_deployment.py`): `GET /rfc/<slug>`,
|
||
`/rfc/<slug>/pr/<n>`, and `/proposals/<n>` permanently redirect to
|
||
`/p/<default>/e/<slug>[…]` / `/p/<default>/proposals/<n>` (§5/§22.10), so
|
||
external "RFC-0001"-style links and bookmarks keep working.
|
||
|
||
Breaking:
|
||
|
||
- **`VITE_APP_NAME` is removed.** The build no longer reads it and no longer
|
||
fails without it; the deployment name comes from the registry
|
||
(`deployment.name` in `projects.yaml`) served at runtime via
|
||
`GET /api/deployment`. The same build now serves any deployment. The
|
||
build-time `%VITE_APP_NAME%` HTML token and the `inject-app-name` Vite
|
||
plugin are gone; `index.html` ships a static `<title>RFC</title>` and JS
|
||
sets the real title after config loads.
|
||
- **Entry/proposal URLs moved under `/p/<project>/`.** The old SPA routes
|
||
`/rfc/:slug`, `/rfc/:slug/pr/:n`, `/proposals/:n` are removed from the SPA
|
||
and served as backend 308s instead — so nginx must route `/rfc/` and
|
||
`/proposals/` to the backend rather than the SPA `index.html`.
|
||
|
||
Scope boundary (informational, not breaking): RFC *data* is still served
|
||
unscoped for the default project this slice — per-project corpus serving is
|
||
the next backend slice (Plan B). Non-default projects show the placeholder.
|
||
|
||
Upgrade steps:
|
||
|
||
1. **MUST** update nginx: add `location /rfc/` and `location /proposals/`
|
||
blocks that `proxy_pass` to the backend, **before** the SPA `location /`
|
||
fallback. The framework's `deploy/nginx/ohm.wiggleverse.org.conf` (and
|
||
`testing/web.nginx.conf`) already carry them; a deployment running a custom
|
||
vhost MUST add them, or old corpus-root URLs 404 against the SPA instead of
|
||
redirecting.
|
||
2. **MUST** ensure the registry `projects.yaml` `deployment.name` is set — it
|
||
is now the header brand and tab title (already required since 0.33.0).
|
||
`tagline` shows on the directory.
|
||
3. **SHOULD** remove `VITE_APP_NAME` from `frontend/.env`; it is no longer
|
||
read (no error if left — simply ignored).
|
||
4. **MUST** rebuild the frontend and deploy. Verify: the header shows the
|
||
deployment name from `/api/deployment`; `/` lands in your project (N=1) or
|
||
shows the directory (2+); an old `/rfc/<slug>` URL **308**-redirects to
|
||
`/p/<default>/e/<slug>`; `/api/health` is green.
|
||
5. **MAY** note: the Tier-1 Playwright e2e for these flows lands once the
|
||
Tier-1 Docker stack seeds a registry repo + `projects.yaml` (`REGISTRY_REPO`
|
||
is unset in `testing/.env.tier1` today, so the dockerized backend can't boot
|
||
there post-0.33.0). This slice is covered by Vitest unit tests
|
||
(`DeploymentProvider`, `ProjectLayout` theme/guard, `Directory`) + the
|
||
backend redirect tests (`backend/tests/test_api_deployment.py`).
|
||
|
||
## 0.34.0 — 2026-06-04
|
||
|
||
**Minor — containerize rfc-app for per-PR preview environments (flotilla
|
||
SPEC §15). Additive: production is unaffected — it still deploys via the
|
||
pin-based on-VM gesture. No upgrade steps for existing deployments.**
|
||
|
||
Adds a `Dockerfile` (+ `deploy/preview/`) so the operator's flotilla can build
|
||
a PR's tree and run it as an ephemeral, scale-to-zero Cloud Run preview with a
|
||
seeded **synthetic** database and **zero real secrets** (test-secret env only):
|
||
|
||
- `Dockerfile` — multi-stage: Vite SPA build → Python runtime serving the SPA
|
||
via nginx on `$PORT` and reverse-proxying `/api/`,`/auth/` to a single-process
|
||
uvicorn on `127.0.0.1:8000` (mirrors the prod nginx + systemd split, minus
|
||
TLS, minus prod secrets). Single process, single SQLite file (§4.2).
|
||
- `deploy/preview/entrypoint.sh` — renders nginx against Cloud Run's `$PORT`,
|
||
boots uvicorn (which runs migrations), and applies the synthetic seed on a
|
||
fresh DB.
|
||
- `deploy/preview/seed.sql` — version-controlled synthetic fixture (no PII).
|
||
- `deploy/preview/preview.env.example` — the test-secret env shape (Cloudflare
|
||
always-pass Turnstile keys, a Mailpit SMTP sink, analytics no-op'd) the
|
||
operator loads into flotilla's preview overlay layer.
|
||
|
||
The framework still knows nothing about flotilla — the image is a generic
|
||
container of rfc-app; flotilla is one possible orchestrator of it.
|
||
|
||
## 0.33.0 — 2026-06-04
|
||
|
||
**Minor (breaking) — §22 M3 backend Plan A: project registry mirror +
|
||
runtime config. The framework now learns its projects from
|
||
`REGISTRY_REPO/projects.yaml`; `META_REPO` is retired. Migration `027`
|
||
runs automatically on deploy.**
|
||
|
||
Added:
|
||
|
||
- **Project registry mirror** (`app/registry.py`): the framework reads
|
||
`projects.yaml` from `REGISTRY_REPO` and mirrors its `deployment:`
|
||
block and `projects:` entries into the `projects` table + a new
|
||
`deployment` singleton. The mirror runs at startup (reconciler) and
|
||
on every §4 webhook push to the registry repo.
|
||
- **`GET /api/deployment`** — returns `name`, `tagline`, and the list
|
||
of visible projects (each item carries `id`, `name`, `type`,
|
||
`visibility`). Replaces the build-time `VITE_APP_NAME` as the
|
||
authoritative runtime config source (the frontend cut lands in
|
||
M3-frontend).
|
||
- **`GET /api/projects/:id`** — returns `id`, `name`, `tagline`, `type`,
|
||
`visibility`, `initial_state`, and `theme` for a single project.
|
||
- **§22.4b `initial_state`** honored at propose time: new RFCs enter
|
||
the state named by the project's `initial_state` field (default:
|
||
`super-draft`; `bdd` projects default to `active`).
|
||
- **§22.4c `unreviewed` flag**: newly proposed RFCs are flagged
|
||
`unreviewed = true`. Owners clear it via
|
||
`POST /api/projects/:id/rfcs/:slug/mark-reviewed`. The catalog
|
||
accepts `?unreviewed=true` to filter to the review queue.
|
||
- **Migration `027`** (additive): adds `projects.type` /
|
||
`projects.initial_state`, the `deployment` table, and
|
||
`cached_rfcs.unreviewed` / `reviewed_at` / `reviewed_by`.
|
||
|
||
Breaking:
|
||
|
||
- `META_REPO` is retired. The app **refuses to start** if `REGISTRY_REPO`
|
||
is unset. The corpus mirror now reads each project's `content_repo`
|
||
from `projects.yaml` rather than from the `META_REPO` env var.
|
||
|
||
Upgrade steps:
|
||
|
||
1. **MUST** create a registry repo under your deployment's Gitea org.
|
||
2. **MUST** author `projects.yaml` at its root with a `deployment:` block
|
||
(`name`, `tagline`) and one `projects:` entry for your existing corpus:
|
||
```yaml
|
||
deployment:
|
||
name: <your deployment name>
|
||
tagline: <your tagline>
|
||
projects:
|
||
- id: default
|
||
name: <your project name>
|
||
type: document
|
||
content_repo: <your old META_REPO value>
|
||
visibility: public
|
||
```
|
||
Keep `id: default` for this release; the pretty-slug re-stamp lands in
|
||
the next backend slice, before any `/p/` URL is public.
|
||
3. **MUST** set `REGISTRY_REPO=<your registry repo name>` and **MUST**
|
||
remove `META_REPO` (or leave it unset — it is ignored but its presence
|
||
may cause confusion).
|
||
4. **MUST** add a Gitea webhook on the registry repo pointing at
|
||
`/api/webhooks/gitea` (same secret as the corpus webhook).
|
||
5. **MUST** deploy. Migration `027` runs automatically at startup; the
|
||
registry mirror reconciles immediately after. Verify
|
||
`GET /api/deployment` returns your project and `/api/health` is green.
|
||
6. **SHOULD** rebuild the frontend (no new env var is required until
|
||
M3-frontend lands, but the frontend currently still reads
|
||
`VITE_APP_NAME` for the display name).
|
||
|
||
## 0.32.0 — 2026-06-01
|
||
|
||
**Minor — graduation's integer RFC number is now optional, and RFC
|
||
owners + site owners can retire (soft-delete) RFCs. Schema migration
|
||
`025_retired_state.sql` runs automatically on deploy; a frontend rebuild
|
||
applies the UI.**
|
||
|
||
Two changes to the §13 lifecycle:
|
||
|
||
1. **Optional number at graduation (§13.2/§13.3).** Graduation no longer
|
||
hard-requires a valid `RFC-NNNN`. The Graduate dialog's integer-ID
|
||
field is now optional: it is still pre-filled with the next free
|
||
number as a *suggestion*, but the graduating owner may clear it and
|
||
graduate with **no number**. When blank, the entry flips to `active`
|
||
with `id: null` and the **slug remains the canonical identifier**
|
||
(§2.3). `GET …/graduate/check` treats a blank id as valid (`ok:true`);
|
||
`POST …/graduate` accepts a blank/absent `rfc_id` and only validates
|
||
the `^RFC-\d{4,}$` regex + collision check when a number *is* supplied.
|
||
The catalog and RFC view render number-less active entries by their
|
||
slug/title (no "RFC-undefined"). RFC-0001's existing number is
|
||
grandfathered — `cached_rfcs.rfc_id` was already nullable, so no data
|
||
migration was needed for this part.
|
||
|
||
2. **Retire / un-retire — soft delete (§3, §3.1, §13.7).** A fourth entry
|
||
state, `retired`, is added. An RFC's own owners (frontmatter) and site
|
||
`owner`-role holders — **not** app admins — may retire an entry
|
||
(`POST /api/rfcs/<slug>/retire`); it flips to `retired` via an
|
||
auto-merged meta-repo PR, leaving the body and every other field
|
||
(including any integer id) intact. A retired entry is removed from
|
||
**every** browsing surface: the catalog, the RFC/discussion/branch
|
||
views (404), and link pickers/search. It is *not* hard-deleted — the
|
||
entry stays in `rfcs/` (git is truth). Un-retire
|
||
(`POST /api/rfcs/<slug>/unretire`) restores the prior state and is
|
||
**site-owner-only**, so a soft-delete is always recoverable by the
|
||
operator but an RFC owner cannot reverse their own retirement. Site
|
||
owners find retired entries via a new owner-gated admin surface
|
||
(`GET /api/admin/retired-rfcs`, the "Retired" tab) and un-retire from
|
||
there or by navigating directly to the entry.
|
||
|
||
Upgrade steps:
|
||
|
||
- **MUST** run the migration. `025_retired_state.sql` rebuilds
|
||
`cached_rfcs` to widen its `state` CHECK constraint to include
|
||
`retired` (SQLite cannot alter a CHECK in place). It runs automatically
|
||
at startup via the forward-only migration runner; existing rows are
|
||
preserved, and the table is reconstructible from Gitea by the
|
||
reconciler regardless (§4). No operator action beyond a normal deploy.
|
||
- **SHOULD** rebuild the frontend so the optional-id Graduate dialog, the
|
||
Retire affordance, and the owner-only "Retired" admin tab are present.
|
||
- No config or secret changes.
|
||
|
||
## 0.31.4 — 2026-06-01
|
||
|
||
**Patch — bug fix + UI polish: secondary buttons that were invisible on
|
||
light surfaces now render legibly, and the RFC view's breadcrumb action
|
||
bar is harmonized into one coherent control group. CSS-only
|
||
(`frontend/src/App.css`); no schema, API, config, overlay, or secret
|
||
change — a plain frontend rebuild applies it. No upgrade steps. Shipped
|
||
from driver session 0059.0.**
|
||
|
||
`.btn-link` was authored as a *dark-header* utility — white text on a
|
||
translucent-white fill (`rgba(255,255,255,0.15)`), the established
|
||
on-dark pattern for the app header's "Sign out". But the same class is
|
||
reused on **light** surfaces: the RFC breadcrumb action bar
|
||
(`RFCView.jsx`), the PR view's diff-mode toggle and "Edit title" control
|
||
(`PRView.jsx`), the invitations and inbox modals, and the discussion
|
||
panel. On those near-white backgrounds the buttons were white-on-white —
|
||
present in the DOM, fully functional, but visually invisible. The
|
||
reported symptom: on a super-draft's header, "Metadata", "Claim
|
||
ownership", and "Invitations" looked *missing*, while the filled CTAs
|
||
("Start Contributing", "Graduate to RFC repo") rendered fine because
|
||
their fill carried them.
|
||
|
||
The fix is root-cause, not a per-site patch:
|
||
|
||
- The base `.btn-link` rule is now a proper light-surface secondary
|
||
button (white fill, hairline `--c-gray-300` border, `--c-gray-700`
|
||
label, hover darkens both). This corrects every light-surface reuse at
|
||
once.
|
||
- The original translucent-on-dark treatment is preserved for the one
|
||
legitimate dark-surface use via an `.app-header .btn-link` scope, so
|
||
the header "Sign out" is unchanged.
|
||
- The breadcrumb action bar (`.breadcrumb-actions`) normalizes every
|
||
action — the discuss/contribute toggle, the filled CTAs, and the
|
||
secondary buttons — to one height, radius, and type scale, so the row
|
||
reads as a single intentional control group. The bar now `flex-wrap`s
|
||
instead of clipping buttons off the right edge when the set is wide.
|
||
- The diff-mode toggle's active option now reads as clearly selected
|
||
(filled ink) rather than relying on a weight change alone.
|
||
|
||
Smooth hover transitions and the keyboard focus ring were already
|
||
provided globally by the v0.21.0 interaction-polish layer, so this
|
||
change adds no new motion or focus rules — it only corrects resting-state
|
||
color/contrast and harmonizes sizing.
|
||
|
||
## 0.31.3 — 2026-05-30
|
||
|
||
**Patch — admin Users tab: "Last seen" reads "Never" for unclaimed
|
||
invites. Visual/logic only in `Admin.jsx`. A plain frontend rebuild
|
||
applies it.**
|
||
|
||
An admin-created invite row showed a real-looking "Last seen" timestamp
|
||
identical to "Signed up," implying the invitee had visited when they
|
||
hadn't. Cause: `users.last_seen_at` is `NOT NULL DEFAULT (datetime('now'))`
|
||
(`migrations/001_users_and_audit.sql`) and the invite INSERT
|
||
(`invites.py`) sets neither timestamp, so both default to the
|
||
row-creation instant; `last_seen_at` only advances on a real
|
||
authentication. Since an unclaimed invite has provably never
|
||
authenticated (that unclaimed state is exactly what drives the
|
||
"PENDING INVITE" badge), the Users tab now renders **"Never"** for the
|
||
Last-seen cell of a pending-invite row instead of the misleading
|
||
default. Signed-up (the invite-created date) is unchanged.
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||
|
||
## 0.31.2 — 2026-05-29
|
||
|
||
**Patch — landing (`/`) welcome panel spacing. Visual only: CSS in
|
||
`App.css` (`.welcome`). A plain frontend rebuild applies it.**
|
||
|
||
The welcome read-view was jammed against the catalog divider with no
|
||
top offset and loose, uneven paragraph spacing. Root cause: `.main-pane`
|
||
carries a bare `.main-pane { padding: 0; display: flex }` override (the
|
||
§8 three-column RFC shell) that shadows the earlier padded read-view
|
||
rule, so the pane provides no padding — and `.welcome` (just
|
||
`max-width`) never compensated. The welcome surface now owns its own
|
||
breathing room: 56px top / 48px side gutters, a capped 680px measure,
|
||
a stronger `text-3xl` "Welcome." hero, and even `--space-8` paragraph
|
||
rhythm at `--leading-relaxed`. Applies to both the signed-out and
|
||
signed-in welcome (same `.welcome` class).
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||
|
||
## 0.31.1 — 2026-05-29
|
||
|
||
**Patch — admin Users tab + header UX polish. Visual only: CSS plus
|
||
markup/structure in `Admin.jsx` (no API, schema, config, overlay, or
|
||
secret change). A plain frontend rebuild applies it.**
|
||
|
||
Two latent CSS defects fixed:
|
||
|
||
- **`.invite-badge` had no rule.** The "(pending invite)" marker on
|
||
admin-created-but-unclaimed user rows rendered as bare parenthetical
|
||
text. It's now a quiet amber pill, consistent with the other status
|
||
badges.
|
||
- **`.btn-link-quiet` never reset native button chrome.** Used as a
|
||
bare link-style `<button>` (admin Revoke / Grant / Remove, the modal
|
||
close ×, and link-buttons in Login / BetaPending), it kept the
|
||
browser's default grey button box. The reset that the `.otc-login`
|
||
scope already carried is folded into the base rule, so every
|
||
`btn-link-quiet` is now a true quiet link.
|
||
|
||
Users-tab cleanups, all token-based:
|
||
|
||
- Table column headers no longer wrap (`WRITE-MUTED` was breaking onto
|
||
two lines); timestamps render as an intentional date-over-time stack
|
||
instead of a ragged mid-value wrap; the duplicated email in a row's
|
||
subline (the handle already *is* the email when there's no Gitea
|
||
login) is de-duplicated; the "Create user + invite" action moves
|
||
flush-right beside the title; inline DB-column references in the
|
||
intro copy read as quiet chips.
|
||
|
||
Header:
|
||
|
||
- **Inbox (§15.2) trigger restyled for the dark header.** It carried a
|
||
light-surface treatment — a `gray-200` border and a `gray-50` hover —
|
||
that rendered as a pale box in the nav and went white-background /
|
||
white-icon (invisible) on hover. It now speaks the nav-link
|
||
vocabulary (`.header-about` et al.): borderless, `gray-300` icon
|
||
brightening to white on a faint translucent hover, unread badge
|
||
unchanged.
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||
|
||
## 0.31.0 — 2026-05-29
|
||
|
||
**Minor — meta-only repository topology (SPEC §1, ROADMAP #36). RFCs no
|
||
longer graduate into their own Gitea repositories; every RFC lives in
|
||
its meta-repo entry (`rfcs/<slug>.md`) for its whole life, and
|
||
graduation is an in-place `super-draft → active` state flip that keeps
|
||
the body in the entry. The per-RFC-repo machinery — repo creation,
|
||
`RFC.md`/`README.md`/`.rfc/metadata.yaml` seeding, body-strip, the
|
||
five-step transactional sequence and its rollback — is removed. This is
|
||
the framework change behind a much simpler deployer story: one content
|
||
repository, every RFC under `rfcs/`.**
|
||
|
||
What changed, concretely:
|
||
|
||
- **Graduation is a single flip.** `POST /api/rfcs/<slug>/graduate` now
|
||
opens one meta-repo PR that re-serializes the entry with
|
||
`state: active`, the integer `id`, and `graduated_at`/`graduated_by`
|
||
— **body unchanged, `repo` left null** — then auto-merges it. No repo
|
||
is created, nothing is seeded, and there is no rollback (an open- or
|
||
merge-failure leaves the entry a super-draft; a failed merge's PR and
|
||
branch are cleaned up). The Graduate dialog drops the **Repo name**
|
||
field (two fields now: integer ID + owners) and the progress stack is
|
||
two steps (`open_pr`, `merge_pr`).
|
||
- **Active RFCs edit on the meta repo.** Branch/PR/chat dispatch keys on
|
||
meta-residency (`repo IS NULL`) rather than `state == 'super-draft'`,
|
||
so an active RFC's branches, body-edit PRs, threads, flags, and
|
||
`changes` all live on the meta repo exactly as a super-draft's do.
|
||
`promote-to-branch` names an active RFC's auto-branch
|
||
`edit-<slug>-<hex>` so the shared-repo cache can attribute it.
|
||
- **Open body-edit PRs no longer block graduation** (§9.8) — the body is
|
||
kept, so they coexist with the flip.
|
||
- **`refresh_rfc_repo` and the per-RFC read path are dead** for
|
||
meta-only entries (the reconciler only sweeps entries with a non-null
|
||
`repo`, of which there are none after the fold-back below).
|
||
|
||
**Upgrade steps:**
|
||
|
||
- Deployments **MUST** fold any already-graduated per-RFC-repo RFC back
|
||
into its meta entry before/with this deploy: restore the per-RFC
|
||
`RFC.md` body into `rfcs/<slug>.md`, set `repo: null` (keep
|
||
`state: active` and the integer `id`), and archive the per-RFC repo.
|
||
An entry left with a non-null `repo` keeps using the retained legacy
|
||
read path, but **no new** per-RFC repos are ever created. For OHM,
|
||
RFC-0001 `human` was folded back in driver session 0041.0 (§13.6).
|
||
- No schema migration, no new config, no new secret, no overlay change.
|
||
A plain code deploy applies it; the running reconciler reconciles the
|
||
catalog on its next sweep (≤5 min).
|
||
- The `repo:` frontmatter field and the `/api/rfcs/<slug>/blocking-prs`
|
||
endpoint are **retained** (the field for schema stability + legacy
|
||
entries; the endpoint as an informational, non-blocking surface), so
|
||
no client contract is removed — `graduate/check` simply no longer
|
||
returns a `repo` field and never reports `blocking_prs` as a gate.
|
||
|
||
## 0.30.2 — 2026-05-29
|
||
|
||
**Patch — header nav label: the persistent chrome link reverts from
|
||
"Philosophy" back to "About." Display text only — the route
|
||
(`/philosophy`), the `header-about` class, and the page itself are
|
||
unchanged. A plain frontend rebuild applies it; no schema, API, config,
|
||
overlay, or secret change.**
|
||
|
||
The §14.3 persistent link was relabeled "About" → "Philosophy" in
|
||
v0.21.0. This restores "About" as the neutral, framework-native label
|
||
(the CSS class `header-about` and the surrounding comment already call
|
||
it "the About link"). A deployment that wants a more pointed framing
|
||
can title its own About page in the `PHILOSOPHY.md` content the
|
||
`PHILOSOPHY_PATH` override serves.
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy.
|
||
|
||
## 0.30.1 — 2026-05-29
|
||
|
||
**Patch — bug fix: a merged idea-PR whose branch was deleted no longer
|
||
lingers as a phantom "pending idea." No schema, API, config, overlay,
|
||
or secret change — a plain code deploy applies it, and the running
|
||
reconciler clears any existing ghost on the next sweep (≤5 min) once
|
||
deployed. Shipped from driver session 0040.0.**
|
||
|
||
`refresh_meta_pulls` (and `refresh_rfc_repo`) recover a PR's slug/kind
|
||
by parsing its Gitea `head.ref`. When a PR is merged **and its branch
|
||
deleted**, Gitea stops reporting the real branch name and returns the
|
||
synthetic `refs/pull/<N>/head` sentinel instead. The slug then parsed
|
||
to `None`, the reconcile loop skipped the row, and `cached_prs.state`
|
||
stayed frozen at `open` forever — so the entry showed as **both** a
|
||
super-draft (the `cached_rfcs` push-event reconcile succeeded) **and** a
|
||
pending idea (the `cached_prs` PR-close reconcile never landed). The fix
|
||
recovers the original branch name from the already-stored `cached_prs`
|
||
row (which retains the real `head_branch` from when the PR was open;
|
||
migration 002) whenever Gitea reports an empty or `refs/pull/` sentinel
|
||
ref. Regression test added in `test_propose_vertical.py`
|
||
(`test_merged_idea_pr_with_deleted_branch_clears_proposal`).
|
||
|
||
Surfaced through the ROADMAP #35 operator authoring lane, which merges
|
||
idea PRs from the CLI with branch-deletion enabled — a path the web UX
|
||
never exercises (it leaves branches in place, so
|
||
`default_delete_branch_after_merge` stays false). The framework should
|
||
not depend on branches outliving their merge, hence the framework-level
|
||
fix rather than a tooling workaround.
|
||
|
||
Upgrade steps: none. **SHOULD** deploy as a normal code deploy; the
|
||
periodic reconciler self-heals any existing phantom on its next sweep.
|
||
|
||
## 0.30.0 — 2026-05-29
|
||
|
||
**Minor — documentation: the user guide (`DOCS.md`, served at
|
||
`/docs/user-guide` via `/api/docs`) brought back in sync with the
|
||
shipped app. No code, schema, API, config, overlay, or secret change —
|
||
a plain code deploy serves the updated guide. Shipped from driver
|
||
session 0037.0.**
|
||
|
||
The guide had drifted since it was first written: it still described
|
||
the pre-OTC email *allowlist* sign-in, listed four propose-RFC fields,
|
||
and predated several shipped surfaces. Updated to match v0.7.0–v0.29.0:
|
||
|
||
- **Signing in** rewritten for the email + one-time-code flow (v0.7.0),
|
||
optional passcode (v0.10.0), trust-this-device for 30 days (v0.11.0),
|
||
optional Cloudflare Turnstile (v0.12.0), and the beta-access request →
|
||
`pending` → admin-`granted` gate (v0.8.0 / #6), plus the admin-create
|
||
+ invite-claim path (v0.17.0 / #16). The vestigial allowlist is no
|
||
longer described as the gate.
|
||
- **Proposing a new RFC** now lists five fields, adding the optional
|
||
"What will you be using this RFC for?" use-case field (#26) and noting
|
||
the AI tag-suggestion disclosure (#27).
|
||
- **Roles & permissions** documents the `pending` state.
|
||
- New **Invitations, cross-references, and contribution requests**
|
||
section covers owner invitations (#12) and the RFC auto-link /
|
||
create-RFC / ask-to-contribute affordances (#28).
|
||
- New **Privacy and cookies** section covers the consent banner and
|
||
consent-gated analytics (#11 / #13).
|
||
|
||
Upgrade steps: none — documentation-only; the change is the `DOCS.md`
|
||
file served verbatim by `/api/docs`. A plain code deploy at this tag
|
||
serves it. A deployment that overrides the guide via `DOCS_PATH`
|
||
supplies its own copy and is unaffected.
|
||
|
||
## 0.29.0 — 2026-05-28
|
||
|
||
**Minor — roadmap #28 Parts 2 + 3: offer-to-create-an-RFC for strong-
|
||
candidate terms, and offer-to-contribute-to-a-pending-RFC. One auto-
|
||
applied migration (024, additive: a new `contribution_requests` table).
|
||
No config/overlay/secret change; no nginx/systemd change. A plain code
|
||
deploy + the auto-migration picks it up. Shipped from driver session
|
||
0033.0.**
|
||
|
||
Both parts extend the v0.26.0 (#28 Part 1) read-time scanner
|
||
(`backend/app/rfc_links.py`) and its renderer
|
||
(`frontend/src/components/LinkedText.jsx`). The scanner now sorts each
|
||
matched term into one of three buckets — active link (Part 1, unchanged),
|
||
pending-RFC contribute offer (Part 3), create-RFC offer (Part 2) — in one
|
||
pass, with precedence active > pending > candidate at any position. The
|
||
backend still emits only structured segments (never HTML), so the surface
|
||
stays XSS-safe by construction.
|
||
|
||
- **Part 2 — create-RFC offers.** A *strong-candidate* term — a
|
||
**multi-word tag** from the #27 tag taxonomy that has no defining RFC
|
||
(no active or super-draft RFC whose slug/title is that term) — renders,
|
||
for a viewer with create rights (`permission_state='granted'`), as an
|
||
inline "+ create RFC" affordance. Clicking opens the propose-RFC modal
|
||
with the term pre-filled as the title (`ProposeModal` gained an
|
||
`initialTitle`; the affordance routes via `?propose=<term>`, read in
|
||
`App.jsx`). The heuristic is deliberately conservative — multi-word is
|
||
the same false-positive guard the title rule uses, so a single common
|
||
tag word (`identity`) is never offered. Broader candidate detection
|
||
(capitalized phrases mined from text, terms repeated across recent PRs,
|
||
or the #27 Haiku `ANTHROPIC_API_KEY` pathway) is a sanctioned but
|
||
deferred extension.
|
||
- **Part 3 — contribute-to-pending offers.** A term matching a *pending*
|
||
RFC — a super-draft (`state='super-draft'`: accepted as an idea, owned,
|
||
with a contribution surface, not yet graduated) — renders, for a
|
||
signed-in non-owner, as an inline "ask to contribute" affordance
|
||
carrying the owner's display name ("<owner> is working on an RFC for
|
||
'<term>'"). It opens a contribute-request form (`?contribute=<slug>`)
|
||
with three fields — **who I am** (required), **why I'm asking**
|
||
(required), **what I'd use it for** (optional, mirroring #26). Submitting
|
||
lands a `contribution_requests` row and one actionable §15 inbox
|
||
notification per owner (new kind `contribution_request_on_pending_rfc`,
|
||
category `personal-direct` — so it reuses the existing
|
||
`email_personal_direct` preference, no new toggle). In the inbox the
|
||
owner sees the requester's who/why/use-case inline with **Accept** /
|
||
**Decline**. Accept fires #12's owner-invite flow with the requester as
|
||
the invitee (a `contributor` `rfc_invitations` row + the existing invite
|
||
email) and echoes a notification back to the requester; Decline closes
|
||
the request and notifies the requester. Pre-merge idea PRs (not yet in
|
||
`cached_rfcs`, no contribution surface) are deliberately out of scope —
|
||
a documented future extension.
|
||
|
||
New endpoints (all under the existing `/api` router):
|
||
`GET /api/rfcs/{slug}/contribution-target`,
|
||
`POST /api/rfcs/{slug}/contribution-requests`,
|
||
`POST /api/rfcs/{slug}/contribution-requests/{id}/accept`,
|
||
`POST /api/rfcs/{slug}/contribution-requests/{id}/decline`.
|
||
|
||
The owner-invite issue path was refactored into one reusable chokepoint,
|
||
`api_invitations.issue_invitation(...)`, shared by the manual invite
|
||
endpoint and Part 3's accept path so the dup-guard, token mint, insert,
|
||
and transactional email stay identical.
|
||
|
||
Upgrade steps:
|
||
|
||
1. Deployments **MUST** apply the auto-run migration `024` (additive: the
|
||
new `contribution_requests` table; no existing table or row is
|
||
touched). The standard deploy path runs pending migrations on start —
|
||
no manual step beyond deploying the new code.
|
||
2. No config, overlay, or secret change is required. The Part 2 candidate
|
||
affordance reuses #27's tag taxonomy; it surfaces only when the corpus
|
||
carries multi-word tags without a defining RFC, and the create
|
||
affordance renders only for beta-granted viewers. Part 3's email reuse
|
||
sends through the existing invitation SMTP path — no new key.
|
||
|
||
## 0.28.0 — 2026-05-28
|
||
|
||
**Minor — security-hardening follow-up (Session-0026 audit, informational
|
||
findings I3 + I4). No operator action required: no migration, no schema
|
||
change, no config/overlay change, no API/behavior change for any caller.
|
||
A plain code deploy picks it up. Shipped from driver session 0032.0.**
|
||
|
||
Two informational findings from the Session-0026 audit, both
|
||
framework-internal defense-in-depth:
|
||
|
||
- **I3 — dead HTML-email branch guarded.** `email_envelope.build_envelope`
|
||
accepted a `body_html=` argument that built a `multipart/alternative`
|
||
body, but no send path ever passed it — every rfc-app mail is plain
|
||
text. An unused branch that would emit HTML built from (potentially
|
||
unescaped) user content is the C1 stored-XSS class waiting in the mail
|
||
channel. The branch is now a loud guard: passing `body_html` raises
|
||
`NotImplementedError`. The argument is kept in the signature for
|
||
documented future symmetry; enabling HTML mail becomes a deliberate
|
||
change that MUST HTML-escape user content at the call site and remove
|
||
the guard in the same commit.
|
||
- **I4 — Turnstile siteverify no longer blocks the event loop.**
|
||
`turnstile.verify_token` was a synchronous function issuing a blocking
|
||
`httpx.post` from inside the async `/auth/otc/request` handler, so a
|
||
slow CloudFlare response stalled the single worker for up to the 10s
|
||
timeout. It is now `async` and awaits the call on an
|
||
`httpx.AsyncClient` (matching the codebase's existing async-httpx
|
||
pattern), isolated behind a narrow `_siteverify_post` seam. The sole
|
||
caller (`main.py`) now `await`s it.
|
||
|
||
Upgrade steps: **none.** Both changes are internal. The `verify_token`
|
||
signature changed from sync to `async` (callers must `await`), but the
|
||
only caller is in-tree (`main.py`) and is updated in this release; no
|
||
deployment-facing surface, config key, or migration is affected.
|
||
|
||
## 0.27.0 — 2026-05-28
|
||
|
||
**Minor — security-hardening release (Session-0026 audit remediation).
|
||
One auto-applied migration (023); one behavior change that re-prompts
|
||
device-trust; deployments MUST re-apply the nginx + systemd files.**
|
||
This is the work cut as the "v0.25.0 security-hardening" branch; it
|
||
reversioned to 0.27.0 because v0.26.0 (#28) took the next slot while it
|
||
was in flight. Shipped from driver session 0030.0.
|
||
|
||
- **C1 (Critical) — stored-XSS closed.** Every markdown→HTML sink now
|
||
routes through one chokepoint, `frontend/src/lib/sanitizeHtml.js`
|
||
(DOMPurify), before any `innerHTML` / `dangerouslySetInnerHTML` write:
|
||
`MarkdownPreview`, both `ProposalView` sinks (entry body +
|
||
`proposed_use_case`), and `Editor`. A hook adds
|
||
`rel="noopener noreferrer"` to `target=_blank` links. `marked` no
|
||
longer passes raw HTML / `javascript:` URIs to the DOM, so a
|
||
contributor can no longer plant a payload that runs in an admin/owner
|
||
session during review.
|
||
- **H1 — OTC verify is rate-limited.** New `backend/app/ratelimit.py`
|
||
(per-IP token buckets) gates `/auth/otc/verify`, `/auth/otc/request`,
|
||
and the passcode check/verify paths; a per-account OTC-verify lockout
|
||
(migration `023_otc_verify_lockout.sql`) mirrors the passcode lockout.
|
||
- **M1 — device-trust lookup no longer table-scans.** The device-trust
|
||
cookie value is now `"<row_id>.<raw_token>"`; `device_trust.lookup`
|
||
reads the one indexed row and bcrypt-checks only it, instead of
|
||
bcrypt-checking every row in the table on each unauthenticated
|
||
`/auth/device-trust/start`.
|
||
- **M2 — HTTP security headers** (CSP, HSTS, X-Frame-Options,
|
||
X-Content-Type-Options, Referrer-Policy) added to the nginx server
|
||
block. **L8/I1**: `server_tokens off` + legacy TLS1.0/1.1 removed.
|
||
- **M4 — session cookie `Secure` by default** (`SESSION_COOKIE_SECURE`,
|
||
defaults on; a dev box on plain http sets it `false`).
|
||
- **M5 — bounce webhook fails closed.** An unset
|
||
`WEBHOOK_EMAIL_BOUNCE_SECRET` now **disables** `/api/webhooks/email-bounce`
|
||
(503) instead of leaving it open; a dev opts back in with
|
||
`RFC_APP_INSECURE_BOUNCE_WEBHOOK=1`.
|
||
- **L2/L3** per-IP cooldown + check-endpoint throttle. **L4** systemd
|
||
sandbox knobs (`CapabilityBoundingSet=`, `ProtectKernel*`,
|
||
`RestrictAddressFamilies`, `SystemCallFilter`, …).
|
||
|
||
Upgrade steps:
|
||
|
||
1. **Migration** — none manual; `023_otc_verify_lockout.sql` auto-applies
|
||
at startup via `db.run_migrations`.
|
||
2. **Device trust (MUST expect re-prompt)** — the cookie format changed,
|
||
so existing "trusted device" cookies no longer match; affected users
|
||
are re-prompted for device verification once. No data migration; old
|
||
rows are simply never matched and age out.
|
||
3. **nginx + systemd (MUST apply out-of-band)** — the deploy gesture does
|
||
**not** install `deploy/nginx/ohm.wiggleverse.org.conf` or
|
||
`deploy/systemd/rfc-app.service`. After deploying the code, copy both
|
||
to their system locations, then `nginx -t && systemctl reload nginx`
|
||
and `systemctl daemon-reload && systemctl restart <unit>`. (M2 headers
|
||
and L4 sandboxing do not take effect until this is done.)
|
||
4. **Bounce webhook (SHOULD)** — bind `WEBHOOK_EMAIL_BOUNCE_SECRET` (or
|
||
set `RFC_APP_INSECURE_BOUNCE_WEBHOOK=1` for dev). Unset → the endpoint
|
||
returns 503 (closed). No legitimate bounce source is wired today, so
|
||
503 is the safe default.
|
||
5. **Session cookie (SHOULD, dev only)** — a deployment served over plain
|
||
http MUST set `SESSION_COOKIE_SECURE=false` or the session cookie
|
||
won't be sent. Production over HTTPS leaves it unset (Secure on).
|
||
|
||
## 0.26.0 — 2026-05-28
|
||
|
||
**Minor — no schema migration, no new secret, no config, no upgrade
|
||
steps. Additive read-time enrichment; deployments inherit it on deploy
|
||
with nothing to set.** Roadmap item #28 **Part 1**: references to existing
|
||
**accepted** RFCs inside PR descriptions and comment text now render as
|
||
inline links to the referenced RFC. Shipped from driver session 0029.0,
|
||
in parallel with the v0.25.0 security-hardening session — hence the
|
||
version-slot gap (0.25.0 is that session's; this took the next free slot
|
||
per the roadmap's "claims the next available version number" rule).
|
||
|
||
Parts 2 (offer-to-create-RFC for strong-candidate terms) and 3
|
||
(offer-to-contribute-to-a-pending-RFC) of item #28 are deliberately **not**
|
||
in this release — Part 1 ships first as the easy win, exactly as the
|
||
roadmap row anticipates.
|
||
|
||
- **Where it links.** The PR review page's description and review
|
||
comments (`GET /api/rfcs/{slug}/prs/{n}`), and the PR-less per-RFC
|
||
discussion comments (`GET /api/rfcs/{slug}/discussion/threads/{id}/messages`).
|
||
Branch-chat (the AI-collaboration surface) is intentionally out of
|
||
scope for Part 1 — a noted follow-up.
|
||
- **Read-time, not submit-time.** The roadmap row phrases the scan as
|
||
happening "at submit/post time"; this ships it as **read-time**
|
||
enrichment instead. The intent the row actually names — "not as live
|
||
compose preview" — holds (drafts are never scanned, only submitted
|
||
content on read). Read-time was chosen because (a) links track the
|
||
*live* accepted-RFC set — a newly-accepted RFC starts linking in older
|
||
comments, a withdrawn one stops linking everywhere — rather than
|
||
freezing stale at submit; (b) it needs **no migration** (no derived
|
||
data to persist); (c) the active-RFC corpus is small and cache-resident,
|
||
so per-read scanning is cheap. (Recorded as a §19.3-rule-2 spec note in
|
||
the session transcript.)
|
||
- **XSS-safe by construction.** The backend returns structured *segments*
|
||
(a list of `{type:"text"}` / `{type:"rfc", slug, label, title}` items),
|
||
never HTML. The new `LinkedText` frontend component maps segments onto
|
||
React text nodes and anchors — no `dangerouslySetInnerHTML` — so a
|
||
comment author cannot inject markup through this path. Every enriched
|
||
field keeps its raw `text`/`description` alongside the `*_segments`, so
|
||
any non-segment-aware caller is unaffected.
|
||
- **Conservative matching (false-positive-averse).** A reference links
|
||
only when it is unlikely to be coincidental: an `rfc_id` token
|
||
(`RFC-0001`), a **multi-word** title (`Open Human Model`), or a
|
||
**hyphenated** slug (`open-human-model`). A single common-word title or
|
||
slug (a hypothetical RFC titled "Human") is **not** auto-linked — that
|
||
would turn every prose "human" into a link. Matching is case-insensitive,
|
||
word-boundary-anchored, longest-match-wins, and suppresses an RFC's
|
||
self-references inside its own PR/discussion. The roadmap's "curated
|
||
canonical-terms list" remains an explicit future opt-in rather than a
|
||
guessed-at default. New module: `backend/app/rfc_links.py`.
|
||
|
||
Tests: 12 new (`test_rfc_links_vertical.py`) — 9 scanner units (gating
|
||
rules, word boundaries, longest-match, case-insensitivity, casing
|
||
preservation, the rfc_id/multi-word/hyphenated gates) plus 3 end-to-end
|
||
(PR description + review comment + discussion comment all surface
|
||
`*_segments`; self-reference suppression). Full suite 363 green; frontend
|
||
builds clean.
|
||
|
||
Upgrade steps:
|
||
|
||
1. None. This release is purely additive: no migration, no new
|
||
environment variable, no secret, no overlay. A deployment picks up RFC
|
||
auto-linking the moment it deploys this version. (RFCs only link once
|
||
they are in the `active` state — proposed/super-draft and withdrawn
|
||
RFCs are never link targets, matching the §11.3 universal-public read
|
||
rule.)
|
||
|
||
## 0.24.0 — 2026-05-28
|
||
|
||
**Minor — one new secret required before this version serves tag
|
||
suggestions; no schema migration; no behavior change for deployments
|
||
that do not bind the key.** Roadmap item #27: as a contributor fills in
|
||
the propose-RFC form, the backend asks Claude Haiku to recommend tags
|
||
drawn from the collection's existing tag set, surfaced as clickable
|
||
suggestion chips. Shipped from driver session 0025.0. This is the §9.1
|
||
"Slice 2" AI-suggested chips that the propose modal has carried a
|
||
deferred placeholder for since Slice 1.
|
||
|
||
- **`POST /api/rfcs/suggest-tags`** (contributor-gated, per-user
|
||
rate-limited). Takes the partial draft (`title`, `pitch`,
|
||
`use_case`) and returns `{ "suggestions": [{ "tag", "confidence" },
|
||
…] }`. The model is constrained to the corpus's existing tags — v1
|
||
tags are free-form chip input, so "the taxonomy" is the de-facto set
|
||
of distinct tags the existing RFCs carry. The model MUST NOT invent
|
||
tags; taxonomy extension is a deliberate out-of-scope follow-up.
|
||
- **Always Claude Haiku, for cost.** Tag suggestion uses Haiku
|
||
regardless of the `ENABLED_MODELS` chat-picker universe, via the new
|
||
`providers.construct_haiku()` factory. There is no RFC slug at propose
|
||
time, so the §6.7 per-RFC funder credential path does not apply — the
|
||
surface runs on the operator's own `ANTHROPIC_API_KEY`.
|
||
- **Degrades to silence, never error.** No key bound, an empty corpus,
|
||
an empty draft, a rate-limited caller, or an unparseable model reply
|
||
all yield an empty list; the propose modal hides its suggestion row,
|
||
and the rest of the app is unaffected. So a deployment that does not
|
||
set the key sees no change at all.
|
||
- **Privacy disclosure.** The modal carries an inline note that the
|
||
draft text is sent to Anthropic to generate the suggestions. This is
|
||
required for honesty (the text leaves the deployment before the RFC
|
||
is submitted); cookie-consent does not gate it because the user has
|
||
actively typed into a draft surface. The exact wording wants a
|
||
counsel pass before OHM's deploy, per the #22 drafting discipline.
|
||
- **Frontend.** `ProposeModal` debounce-posts the draft (700 ms, with a
|
||
stale-response guard); suggestion chips are clickable and add to the
|
||
tag list (nothing auto-applies). `api.suggestTags()` is forgiving —
|
||
any non-OK response resolves to `[]`.
|
||
|
||
Tests: 11 new (`test_tag_suggest_vertical.py`) — contributor-gating,
|
||
universe-constrained filtering, no-key / empty-corpus / rate-limit
|
||
paths, plus units for the universe gather, the tolerant reply parser,
|
||
the max cap, the empty-draft short-circuit, and provider-failure
|
||
fallback. Full suite 351 green.
|
||
|
||
Upgrade steps:
|
||
|
||
1. You **MUST** bind `ANTHROPIC_API_KEY` for the suggestion surface to
|
||
work. On OHM this is an operator gesture — the key never touches the
|
||
conversation. Set it from your own terminal via stdin (so the bytes
|
||
never land in shell history):
|
||
|
||
```bash
|
||
printf '%s' "$(pbpaste)" | .venv/bin/ohm-rfc-app-flotilla \
|
||
secret set ohm-rfc-app ANTHROPIC_API_KEY
|
||
```
|
||
|
||
(The §18 chat stack already reads this same key, so a deployment
|
||
that has chat configured **MAY** already have it bound — confirm with
|
||
`flotilla secret list ohm-rfc-app`.) If the key is absent the app
|
||
still boots and serves normally; tag suggestions are simply
|
||
unavailable until it is set.
|
||
2. You **MAY** tune the per-user rate limit via `TAG_SUGGEST_RATE_MAX`
|
||
(default 30) and `TAG_SUGGEST_RATE_WINDOW_SECONDS` (default 60). The
|
||
defaults apply when unset.
|
||
3. You **SHOULD** have counsel review the modal's disclosure wording
|
||
before exposing the surface to users, per the #22 drafting
|
||
discipline — the copy is honest as written, but the legal review is
|
||
the right call for any "your text is sent to a third party" notice.
|
||
|
||
## 0.23.0 — 2026-05-28
|
||
|
||
Roadmap item #29: signing in lands the user on their most recently
|
||
viewed app state instead of the empty-state home view. Shipped from
|
||
driver session 0022.0. Per-user, server-side, on by default; first-ever
|
||
sign-ins still land on home.
|
||
|
||
1. **Server-side last-state.** A new `user_session_state` table holds one
|
||
row per user: `last_route TEXT`, `last_route_state TEXT` (light view
|
||
state encoded as JSON — SQLite has no native JSONB — never draft-buffer
|
||
contents), `resume_enabled INTEGER NOT NULL DEFAULT 1`, and
|
||
`last_updated_at`. Per-user (not per-device), matching the
|
||
"go to where I was last" intent.
|
||
2. **Route-change capture.** A new frontend hook (`frontend/src/lib/
|
||
useLastState.js`) debounce-posts the current route + light state to
|
||
`PUT /api/me/last-state` for authenticated users only; anonymous
|
||
sessions are a no-op. The handler upserts the user's row and no-ops
|
||
when `resume_enabled` is 0.
|
||
3. **Sign-in redirect.** The stored `last_route` (+ decoded state +
|
||
`resume_enabled`) is folded onto the existing `/api/auth/me` payload
|
||
(no new GET endpoint), so the frontend reads it on boot. On a hard
|
||
sign-in landing (app booted on `/`), the app redirects to the stored
|
||
route. The redirect is gated on the #21-Part-C Amplitude `identify`
|
||
having fired (`identifyReady`), preserving identify-then-track
|
||
ordering — the first event on the resumed route carries the user_id.
|
||
4. **Edge cases.** Stale routes (an RFC since withdrawn or now
|
||
unreadable) are a graceful no-op — existing routing falls through to
|
||
the catalog/empty-state. Opt-out ships at the column level
|
||
(`resume_enabled`); the profile-settings toggle UI is a follow-up.
|
||
Stored state is route + light view state only, never draft contents
|
||
(documented in `SPEC.md` §6.8).
|
||
|
||
Migration `022_user_session_state.sql` creates the table. No new
|
||
environment variables, overlay keys, or secrets.
|
||
|
||
Upgrade steps:
|
||
|
||
1. Deployments **MUST** apply database migrations; `022_user_session_state.sql`
|
||
runs automatically on next boot (the migration runner globs
|
||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||
`schema_migrations`). The migration is additive — a new table — and
|
||
requires no data backfill.
|
||
2. No new environment variables, overlay keys, or secrets. No operator
|
||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||
|
||
## 0.22.0 — 2026-05-28
|
||
|
||
Roadmap item #26: an optional **"What will you be using this for?"**
|
||
field on both propose surfaces. Shipped from driver session 0022.0.
|
||
Additive and backward-compatible — no behavior changes for anyone who
|
||
leaves the field blank.
|
||
|
||
1. **Propose-RFC modal.** Below the required "Why is this RFC needed?"
|
||
field (`pitch`) sits a new optional **"What will you be using this
|
||
RFC for?"** textarea. "Needed" is the abstract justification; "using
|
||
it for" is the concrete ground-truth use case — captured without
|
||
being forced.
|
||
2. **Propose-PR modal.** Below the required "Why is this change needed?"
|
||
field (`description`) sits a new optional **"What will you be using
|
||
this change for?"** textarea.
|
||
3. **Display.** The RFC view (`RFCView` / `ProposalView`) and PR review
|
||
view (`PRView`) render the captured use case alongside the existing
|
||
"why" text, with a muted "left blank" treatment when absent.
|
||
4. **Persistence (deployment note).** In this deployment the framework's
|
||
`rfcs` / PR surfaces are the Gitea-backed cache tables `cached_rfcs`
|
||
/ `cached_prs`, rebuilt by the reconciler. Because the propose write
|
||
path is endpoint → Gitea → reconcile (and the reconciler doesn't
|
||
carry the new field), the durable home is a canonical, reconcile-proof
|
||
side table `proposed_use_cases` (keyed by `(scope, pr_number)`) that
|
||
the propose/open endpoints write directly and the view endpoints read
|
||
back. The literal nullable `proposed_use_case` columns named by the
|
||
roadmap are also added to `cached_rfcs` / `cached_prs` for parity and
|
||
any future reconciler that learns to carry the field. NULL/blank use
|
||
cases simply never write a side-table row — absence is "left blank".
|
||
|
||
Migration `021_proposed_use_case.sql` adds the two cache columns
|
||
(`ALTER TABLE … ADD COLUMN proposed_use_case TEXT`), the
|
||
`proposed_use_cases` canonical table, and its lookup indexes. The
|
||
reconciler's upsert (`ON CONFLICT DO UPDATE`) sets only known columns,
|
||
so the added cache columns survive reconciles. Backend validators accept
|
||
the field as NULL/omitted (no required-validation) with an 8000-char cap
|
||
matching the existing PR `description` bound; blank/whitespace is treated
|
||
as absent.
|
||
|
||
Upgrade steps:
|
||
|
||
1. Deployments **MUST** apply database migrations; `021_proposed_use_case.sql`
|
||
runs automatically on next boot (the migration runner globs
|
||
`backend/migrations/*.sql` and applies any not yet recorded in
|
||
`schema_migrations`). The migration is additive — nullable cache
|
||
columns plus a new table — and requires no data backfill.
|
||
2. No new environment variables, overlay keys, or secrets. No operator
|
||
action beyond the standard `flotilla deploy ohm-rfc-app`.
|
||
|
||
## 0.21.0 — 2026-05-28
|
||
|
||
UX-polish wave. Roadmap items #31 (comprehensive UX polish — foundation
|
||
slice), #24 (header "About" → "Philosophy"), #25 (inbox icon + light UX),
|
||
and #32 (session/transcript page polish). Pure frontend; no schema, no
|
||
backend changes, no new secret. Shipped from one driver session (0019.0)
|
||
via three parallel subagents working on disjoint surfaces.
|
||
|
||
1. **Design-token foundation (#31).** New `frontend/src/styles/tokens.css`
|
||
establishes the app's first coherent design system — semantic color
|
||
palette, type scale, spacing scale, radius scale, elevation, and a
|
||
motion vocabulary (with `prefers-reduced-motion` honored) — as CSS
|
||
custom properties, imported first in `main.jsx`. Before this the app
|
||
carried ~98 distinct hardcoded hex colors, font sizes across 16
|
||
unscaled values, and radii across 13. `App.css` and `index.css` were
|
||
swept to the tokens (~630 color / 280 font-size / 128 radius
|
||
references), consolidating near-duplicate grays to the nearest ramp
|
||
step and rounding off-scale type to the nearest step. No CSS class was
|
||
renamed or removed; an additive `:focus-visible` ring and a subtle
|
||
hover/transition layer were added. 35 special-purpose hexes (true
|
||
blues/violets, status dots, deep diff-contrast shades) were
|
||
deliberately left as literals. This is the polish *foundation*; a
|
||
follow-up (#31b) covers the bespoke per-surface re-spacing that wants
|
||
operator review against screenshots.
|
||
|
||
2. **Header: "About" → "Philosophy" (#24).** The persistent header link
|
||
now reads "Philosophy" (the route `/philosophy` and its page already
|
||
existed; only the label changed).
|
||
|
||
3. **Inbox icon + light UX (#25).** The header inbox trigger's `📮`
|
||
emoji is replaced with a dependency-free inline-SVG envelope icon
|
||
(`aria-label="Inbox"`); no icon library was added. The inbox panel
|
||
got a light pass — clearer unread/read distinction, mark-all-read and
|
||
per-row affordances surfaced, better empty state, tokenized spacing in
|
||
a new component-scoped `Inbox.css`. Behavior, filters, deep-links, and
|
||
§15 notification data flow are unchanged. A full inbox redesign is
|
||
deferred to a #25 follow-up (the operator's reference screenshot did
|
||
not transmit).
|
||
|
||
4. **Session/transcript page polish (#32).** `/docs/sessions/<NNNN>` no
|
||
longer dead-ends on a "select a transcript" placeholder: a
|
||
single-transcript session renders that transcript inline at the
|
||
session root; a multi-transcript session renders its `.0` driver
|
||
transcript inline and lists the siblings. Each rendered transcript now
|
||
carries a metadata header — session title, Started/Ended (parsed from
|
||
the filename's ISO segments), derived Duration, an optional one-line
|
||
TL;DR, and a "View source on git.wiggleverse.org" external link to the
|
||
canonical raw transcript. The TL;DR reads an optional `tldr` string on
|
||
the per-session `sessions.json` manifest entry and degrades gracefully
|
||
when absent.
|
||
|
||
Upgrade steps:
|
||
|
||
MAY: add a `tldr` string to any per-session entry in
|
||
`wiggleverse/ohm-session-history`'s `sessions.json`
|
||
(e.g. `"0019": { "title": "…", "tldr": "one-line summary" }`) to surface
|
||
a summary in each transcript's metadata header. Absent `tldr` renders
|
||
nothing — no deployment action is required. This is a data edit in the
|
||
session-history repo, not a `flotilla` gesture.
|
||
|
||
## 0.20.0 — 2026-05-28
|
||
|
||
Wave 9 follow-up to roadmap item #30. Three changes bundled into one minor:
|
||
|
||
1. **Specs on `/docs/specs/<name>`** — a new public surface alongside the user guide that renders the framework's spec corpus at runtime. Configured via the `OHM_DOCS_SPECS` env var; the framework default carries OHM's two specs (`rfc-app/SPEC.md` and `ohm-rfc-app-flotilla/SPEC.md`) fetched from gitea raw URLs with a 5-minute TTL cache. Each spec page renders the current version only — git is the history surface; a "View source" link beside the title points at the upstream raw URL. Bare `/docs/specs` client-side redirects to the first configured spec (or renders a "no specs configured" empty state if the deployment cleared the list).
|
||
|
||
2. **Nested flyout nav hierarchy** — `/docs/*` nav now renders sessions as a tree: each session row has its transcripts nested under it as nav children, labeled by their `.N` ordinal (`0014.0`, `0014.1`, …). Each session's transcript index is fetched alongside the manifest on layout mount (Promise.all over the manifest's keys); the backend's 5-minute content TTL makes the repeat cost negligible. Always-expanded — at the current scale (≤20 sessions) lazy expansion isn't worth the click. A new "Specs" section sits between User Guide and Sessions, populated by the new manifest endpoint.
|
||
|
||
3. **`/docs/sessions/<NNNN>` body-list removed** — operator preference: navigation lives in the left nav, not in body content. The per-session page is now a session-overview card (title + transcript count + "select a transcript from the navigation" hint). Empty-state, not-found, and error paths preserved; only the inline transcript-link list is gone.
|
||
|
||
Upgrade steps:
|
||
|
||
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS='<JSON array>'` to override the configured spec set. Each entry is `{"name": "<slug>", "title": "<human label>", "url": "<gitea raw URL>"}`. Malformed JSON, a non-array root, or an entry that fails validation (missing fields, non-slug `name`) logs a warning and falls back to the framework default; deployment startup is never crashed by a bad value.
|
||
|
||
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SPECS_CONTENT_TTL_SEC=300` to tune the per-spec content cache TTL.
|
||
|
||
Note on the `frontend/package-lock.json` version drift fix: the lockfile's top-level and `packages.""` version fields drifted to `0.15.0` somewhere in the v0.16.0–v0.19.0 window and weren't caught. This release syncs them to `0.20.0` alongside `frontend/package.json` and `VERSION`. No dependency changes; only the version-string fields move.
|
||
|
||
## 0.19.0 — 2026-05-28
|
||
|
||
Roadmap item #30: docs nav with on-site sessions browser. Adds a left-side flyout nav on `/docs/*` and three new public surfaces — `/docs/sessions/about` (renders the session-history README), `/docs/sessions/<NNNN>` (per-session index), `/docs/sessions/<NNNN>/<filename>` (per-transcript view). Backend mediates the fetch from `wiggleverse/ohm-session-history` over gitea raw URLs with a small in-process TTL cache (60 s manifest, 5 min content; both env-tunable). Existing `/docs` content moves to `/docs/user-guide`; bare `/docs` redirects.
|
||
|
||
Upgrade steps:
|
||
|
||
MAY: `flotilla overlay set ohm-rfc-app OHM_SESSION_HISTORY_RAW_BASE=<url>` if the deployment points at a non-OHM transcript repo. Default in code matches OHM's `wiggleverse/ohm-session-history`.
|
||
|
||
MAY: `flotilla overlay set ohm-rfc-app OHM_DOCS_SESSIONS_MANIFEST_TTL_SEC=60` and `OHM_DOCS_SESSIONS_CONTENT_TTL_SEC=300` to tune cache TTLs.
|
||
|
||
Note: this release depends on the parallel restructure of `wiggleverse/ohm-session-history` into per-session `NNNN/` folders + `README.md` + `sessions.json` (driver session 0017.0, subsession 0017.2). If the repo is still flat at deploy time, `/docs/sessions/about` and the per-session pages return 404 and the route tree degrades to "About not yet published" — no JS crashes; the User Guide remains fully functional.
|
||
|
||
## 0.18.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; one env var now mandatory; no
|
||
new secrets.** This release lands the framework-side half of OHM
|
||
roadmap items #18 (Secure the SMTP relay + Gitea webhook) and #20
|
||
(Email deliverability). It is the framework counterpart to the
|
||
operator-side SMTP / DNS hardening covered in the
|
||
`EMAIL-AND-WEBHOOK-HARDENING-RUNBOOK.md` companion doc.
|
||
|
||
The release is shipped in five atomic slices per the v0.18.0 proposal:
|
||
|
||
1. **`build_envelope` shared helper.** A single place where every
|
||
outbound `EmailMessage` is constructed. Lands the
|
||
deliverability-critical headers (`Date`, `Message-ID`,
|
||
`Auto-Submitted`) uniformly across OTC, invite, watcher
|
||
notification, bundle, and digest paths; exposes per-kind
|
||
unsubscribe semantics (none for OTC, mailto: for invites, full
|
||
one-click for bulk-adjacent paths) as explicit kwargs.
|
||
2. **Migrated send paths.** `email_otc.py`, `email_invite.py`,
|
||
`email._deliver`, `email._send_bundle`, and `digest.py` now
|
||
build their envelopes through the helper. Per-RFC invite
|
||
(v0.16.0) rides through `email_invite.py`'s helper and picks
|
||
up the change for free. The shared `_SENT` test buffer also
|
||
carries the constructed `EmailMessage` under `envelope["message"]`
|
||
so tests can assert on the header surface directly.
|
||
3. **Webhook handler tightening.** `GITEA_WEBHOOK_SECRET` is now
|
||
mandatory at startup — the framework refuses to load_config()
|
||
if it's empty, unless the operator explicitly opts into the
|
||
dev-bypass with `RFC_APP_INSECURE_WEBHOOKS=1`. The
|
||
`/api/webhooks/gitea` receiver carries defense-in-depth checks
|
||
that surface the misconfiguration loudly at the request layer
|
||
too. Mis-targeted webhooks (a hook on a fork or a stale Gitea
|
||
binding) now log at INFO instead of silently 200-OK'ing.
|
||
4. **`outbound_emails` audit table + admin endpoint.** Every send
|
||
helper writes one row to `outbound_emails` before returning,
|
||
capturing the send attempt regardless of outcome
|
||
(status='sent' / 'failed' / 'deferred'). The new admin endpoint
|
||
`GET /api/admin/outbound-emails` (filterable by kind / status /
|
||
to_address) lets the operator answer "did this person ever get
|
||
their invite?" without grepping VM logs. No admin UI ships
|
||
with v0.18.0; operator queries via curl + jq for now.
|
||
5. **Bounce correlation.** The `POST /api/webhooks/email-bounce`
|
||
body accepts a new optional `message_id` field; when supplied,
|
||
the handler stamps status='bounced' on the matching
|
||
`outbound_emails` row and returns the row id as
|
||
`correlated_id`. The pre-existing hard-bounce ->
|
||
`email_opt_out_all = 1` flow still fires.
|
||
|
||
The `POST /api/email/unsubscribe` endpoint also lands in Slice 2
|
||
as the matching receiver for the new `List-Unsubscribe-Post:
|
||
List-Unsubscribe=One-Click` header (Gmail and Yahoo POST that
|
||
payload on the user's one-click action per RFC 8058 — the GET
|
||
endpoint alone is no longer sufficient for senders at OHM's tier).
|
||
|
||
### Added
|
||
|
||
- **`backend/app/email_envelope.py`** — the `build_envelope` helper.
|
||
Single source of truth for every outbound `EmailMessage`'s
|
||
headers + body shape. Standalone module so tests can exercise it
|
||
without booting the FastAPI app.
|
||
- **`backend/migrations/020_outbound_emails.sql`** — the audit
|
||
table. Single new table with three indexes (to_address, sent_at,
|
||
message_id); no changes to existing tables.
|
||
- **`email.record_outbound(...)`** — best-effort write helper every
|
||
send path calls. Catches `RuntimeError` (so pure-helper unit
|
||
tests where `db.init()` was never called don't break) and any
|
||
other exception (so the audit write never breaks a send).
|
||
- **`GET /api/admin/outbound-emails`** in `api_admin.py` —
|
||
admin-only listing of `outbound_emails`. Newest-first, filterable
|
||
by kind, status, and to_address (case-insensitive). Returns
|
||
`{items: [...], has_more}` per the rest of the admin endpoints'
|
||
shape.
|
||
- **`POST /api/email/unsubscribe`** in `api_notifications.py` —
|
||
RFC 8058 one-click receiver. Accepts the same `?t=` token as the
|
||
GET handler; idempotent; returns 200 + `{ok, category}` on
|
||
success.
|
||
- **`all` synthetic category** for unsubscribe URLs. Used by the
|
||
bundle + digest paths (which can't honor per-category opt-outs
|
||
because they span multiple categories); flips
|
||
`email_opt_out_all = 1` rather than a per-category column.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/email.py`** — `EmailConfig` gains
|
||
`unsubscribe_mailto` (env: `EMAIL_UNSUBSCRIBE_MAILTO`, default
|
||
falls back to `EMAIL_FROM`). `_deliver` and `_send_bundle`
|
||
build envelopes through `build_envelope` and thread `kind` +
|
||
`notification_id` into the audit write.
|
||
- **`backend/app/email_otc.py`** + **`email_invite.py`** — both
|
||
call `build_envelope` and `record_outbound`. OTC carries no
|
||
`List-Unsubscribe` (recipient explicitly requested the code);
|
||
invite carries `List-Unsubscribe: <mailto:…>` only (no signed
|
||
URL — the invitee isn't a user yet, no per-user opt-out row
|
||
exists).
|
||
- **`backend/app/digest.py`** — calls `_deliver` with `kind='digest'`
|
||
+ the new `all`-category one-click unsubscribe.
|
||
- **`backend/app/webhooks.py`** — refuses 500 at request time if
|
||
the secret is empty + bypass isn't set; logs a loud warning
|
||
per-request when running under the bypass; logs INFO when a
|
||
hook targets a repo not in `cached_rfcs`.
|
||
- **`backend/app/config.py`** — `load_config()` raises
|
||
RuntimeError if `GITEA_WEBHOOK_SECRET` is empty unless
|
||
`RFC_APP_INSECURE_WEBHOOKS=1`.
|
||
- **`backend/app/api_notifications.py`** — GET unsubscribe handler
|
||
accepts the `all` category (sets `email_opt_out_all = 1`).
|
||
Bounce webhook body adds optional `message_id` field; response
|
||
shape adds `correlated_id` field. (Tests that read the exact
|
||
response shape — currently just
|
||
`test_bounce_webhook_refuses_unsigned_when_secret_configured`
|
||
in test_e2e_smoke.py — updated to assert on the new shape.)
|
||
- **`backend/tests/test_propose_vertical.py`** — the shared
|
||
`tmp_env` fixture binds a fake `GITEA_WEBHOOK_SECRET` so all
|
||
252 pre-v0.18.0 tests boot cleanly under the new mandatory
|
||
secret. Tests that want to exercise the dev-bypass path
|
||
monkeypatch `RFC_APP_INSECURE_WEBHOOKS=1` explicitly.
|
||
|
||
### Tests
|
||
|
||
- 15 new unit tests in `test_email_envelope.py` for the helper.
|
||
- 10 new integration tests across `test_otc_vertical`,
|
||
`test_admin_create_user_invite_vertical`, and
|
||
`test_notifications_vertical` covering: OTC has no
|
||
List-Unsubscribe; invite has mailto: only; notification has
|
||
full one-click; POST one-click flips per-category; `all` flips
|
||
global; respects `EMAIL_UNSUBSCRIBE_MAILTO` override.
|
||
- 7 new integration tests in `test_webhooks_vertical.py` covering
|
||
the startup-time mandatory-secret check, the dev-bypass, and
|
||
the request-time signature verification including the unknown-
|
||
repo log line.
|
||
- 11 new integration tests in `test_outbound_emails_vertical.py`
|
||
covering the audit table write path (OTC / invite / notification),
|
||
the admin endpoint (list, filter by kind, filter by to_address,
|
||
non-admin refusal), and the bounce correlation (matched
|
||
message_id stamps status='bounced'; unknown message_id is
|
||
logged; absent message_id falls back to legacy behavior; bounced
|
||
rows surface in admin endpoint with `?status=bounced`).
|
||
- One test updated for intentional response-shape change:
|
||
`test_e2e_smoke.test_bounce_webhook_refuses_unsigned_when_secret_configured`.
|
||
|
||
Full suite: 295 passed (was 252 pre-v0.18.0).
|
||
|
||
### Migration
|
||
|
||
- **`backend/migrations/020_outbound_emails.sql`** — auto-applied
|
||
on next backend start. Single new table with three indexes; no
|
||
changes to existing tables.
|
||
|
||
### Upgrade steps (from 0.17.0)
|
||
|
||
- Operators **MUST** ensure `GITEA_WEBHOOK_SECRET` is set in the
|
||
deployment's env. The framework now refuses to start if it's
|
||
empty. (For OHM-flotilla deployments,
|
||
`flotilla secret list <deployment>` confirms the binding; OHM
|
||
has carried this binding since v0.14.0, so the upgrade is
|
||
gesture-free for OHM specifically.)
|
||
- Operators **MAY** set `RFC_APP_INSECURE_WEBHOOKS=1` to bypass
|
||
the requirement in local-dev environments. Production
|
||
deployments **MUST NOT** set this; if they do, every webhook
|
||
POST logs a loud warning line per request.
|
||
- Operators **MAY** set `EMAIL_UNSUBSCRIBE_MAILTO` to route
|
||
`List-Unsubscribe: <mailto:…>` opt-out courtesy mail to a
|
||
humans-monitored mailbox distinct from the no-reply
|
||
`EMAIL_FROM` sender. Default falls back to `EMAIL_FROM`.
|
||
- You **MUST** apply schema migration
|
||
`020_outbound_emails.sql`. The migration creates a single new
|
||
table with three indexes; the framework runs migrations
|
||
automatically at process start, so no manual step is required
|
||
beyond restarting the backend so the migration runner picks
|
||
the file up. Existing deployments pick it up on first start
|
||
after upgrade with no operator action required.
|
||
- You **MUST** rebuild the frontend and restart the backend
|
||
after upgrading. `frontend/package.json#version` and `VERSION`
|
||
both move to `0.18.0`. No new secrets (the `outbound_emails`
|
||
table writes synchronously to the same SQLite file as every
|
||
other write).
|
||
- Operators **SHOULD** run a `mail-tester.com` probe against the
|
||
upgraded deployment to confirm the new envelope headers
|
||
(`Date`, `Message-ID`, `Auto-Submitted`, `List-Unsubscribe`,
|
||
`List-Unsubscribe-Post`) land cleanly with the upstream SMTP
|
||
relay's DKIM signing. The expected delta from pre-v0.18.0 is
|
||
+2-3 points on the spam-score axis (typical 5-6/10 baseline
|
||
→ 9+/10 post-upgrade).
|
||
|
||
|
||
## 0.17.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; no new env vars; no new secrets.**
|
||
This release lands admin-create user with role assignment + invite
|
||
email (roadmap item #16, §6.1). From the v0.9.0 `/admin/users` surface,
|
||
an admin can now type first name, last name, email, role, and an
|
||
optional custom message; the framework provisions the `users` row with
|
||
the chosen role and `permission_state='granted'` (the admin's hand is
|
||
the grant) and sends an invite email carrying a single-use claim link.
|
||
The invitee clicks through to `/invites/claim?token=…`, the token is
|
||
verified and consumed, the session is established, and the user is
|
||
routed to the passcode-set screen (per v0.10.0) on first sign-in.
|
||
|
||
Distinct from #12 (which ships in parallel in this wave): #12 is
|
||
per-RFC contribution/discussion membership and uses
|
||
`rfc_invitations` (slot 018). v0.17.0 is platform-level access
|
||
provisioning by an admin and uses `user_invite_tokens` (slot 019).
|
||
Both can coexist; both surface in the same SMTP relay but with
|
||
distinct email templates.
|
||
|
||
Design decisions documented inline (see `backend/app/invites.py`'s
|
||
module docstring + the migration's header comment):
|
||
|
||
* **Token shape: opaque DB token, not JWT.** 256 bits of CSPRNG
|
||
entropy (`secrets.token_urlsafe(32)`), bcrypt-hashed at rest.
|
||
Opaque chosen over JWT because revocation is then a single SQL
|
||
UPDATE — admin-issued invites are exactly the kind of thing an
|
||
admin should be able to yank back without rotating a signing
|
||
key. The raw token only ever lives in the outbound email link
|
||
and the inbound claim body.
|
||
* **TTL: 7 days, hard-coded constant** (`INVITE_TOKEN_TTL_DAYS`
|
||
in `backend/app/invites.py`). Env-var configurability is a
|
||
§19.2 candidate; the constant is exposed as a single point of
|
||
edit if a deployment wants to override.
|
||
* **Immediate send, no admin-review-then-send queue.** Matches
|
||
how the v0.9.0 beta-request admin notification works (single
|
||
SMTP path). Admin-preview-before-send is a future enhancement.
|
||
* **Bulk-invite (CSV paste) deferred.** v0.17.0 is one-at-a-time;
|
||
a follow-up release can layer bulk on top of the same
|
||
`POST /api/admin/users` body shape with minimal disruption.
|
||
* **OTC skipped on first sign-in.** Per the roadmap: clicking the
|
||
unique token in the email is itself proof of email control, so
|
||
the claim flow signs the invitee in directly. Subsequent
|
||
sign-ins go through the standard OTC / passcode paths.
|
||
* **No `users` table changes.** The brief floated
|
||
`first_sign_in_at` / `last_seen_at IS NULL` as the
|
||
"(pending invite)" discriminator, but the existing
|
||
`users.last_seen_at` column is NOT NULL with a `datetime('now')`
|
||
default (migrations/001) and no `first_sign_in_at` column
|
||
exists. Rather than land a schema migration to introduce one,
|
||
the discriminator is the existence of an active (not-claimed,
|
||
not-expired) row in `user_invite_tokens` joined on
|
||
`invited_user_id`. The admin user-listing carries a
|
||
`pending_invite` field populated via that join; on claim, the
|
||
badge clears naturally as the invite row's `claimed_at`
|
||
populates.
|
||
* **Admin-create vs. self-flip refusals.** Self-invite is refused
|
||
422 (use the role-change channel for self-edits). Duplicate
|
||
email is refused 409 (use the existing role / grant gestures on
|
||
the existing user). Owner-grant by a non-owner admin is refused
|
||
422 (§6.1's owner-zero is the only owner bootstrap path; the
|
||
sitting owner must issue the invite).
|
||
|
||
### Added
|
||
|
||
- **`POST /api/admin/users`** (`backend/app/api_admin.py`) — admin-only.
|
||
Body: `{ email, first_name?, last_name?, role, custom_message? }`.
|
||
Provisions the `users` row with the chosen role and writes the
|
||
`user_invite_tokens` row + dispatches the invite email + writes a
|
||
`permission_events` row with `event_kind='user_invited'`. Returns
|
||
`{ ok, invite_id, invited_user_id, email, role }` on success;
|
||
surfaces the four refusals (403/422/409/422) per their distinct
|
||
paths.
|
||
- **`GET /api/admin/users/invites`** — admin-only. Lists active
|
||
(not claimed, not expired) invites with the admin who created them
|
||
joined through for display. Powers the "I sent these but they
|
||
haven't been claimed yet" admin view.
|
||
- **`POST /api/invites/claim`** (`backend/app/main.py` — alongside
|
||
`/auth/otc/verify` and `/auth/device-trust/start` since it shares
|
||
the device-trust cookie helpers). Anonymous-reachable. Body:
|
||
`{ token, trust_device? }`. Validates the token, consumes the
|
||
invite row, signs the user in, optionally mints a device-trust
|
||
cookie, and returns `{ ok, user, needs_passcode }`. The
|
||
`needs_passcode` hint drives the frontend's route-to-passcode-set
|
||
vs. route-to-home decision. Maps token-failure modes to distinct
|
||
HTTP statuses: expired/claimed → 410, unknown/invalid → 400.
|
||
- **`backend/app/invites.py`** — the create + claim + list module.
|
||
Mirrors the `device_trust.py` shape: opaque-token issuance with
|
||
bcrypt-at-rest, candidate-set walk on lookup, dataclass-bracketed
|
||
outcomes (`CreateOutcome` / `ClaimOutcome` / `PendingInviteRow`).
|
||
Carries the `INVITE_TOKEN_TTL_DAYS = 7` constant and the
|
||
`CUSTOM_MESSAGE_MAX_LENGTH = 500` mirror of the API-side bound.
|
||
- **`backend/app/email_invite.py`** — sibling of `email_otc.py`. Reuses
|
||
`EmailConfig.from_env()` for the SMTP plumbing + From identity;
|
||
composes a separate template (subject "You're invited to <app> by
|
||
<admin>"; body names the inviter, embeds the optional custom
|
||
message in a clearly-delimited indented block if present, and
|
||
carries the claim URL). Dev / no-SMTP path logs the envelope to
|
||
the shared `_SENT` buffer so backend tests can assert on the
|
||
outbound shape.
|
||
- **Schema migration `019_user_invite_tokens.sql`** — new
|
||
`user_invite_tokens` table (id, email, role, first_name, last_name,
|
||
custom_message, token_hash, expires_at, created_at,
|
||
created_by_admin_id, claimed_at, claimed_by_user_id,
|
||
invited_user_id). Three indexes: unique on `token_hash`
|
||
(documents the no-collision invariant); `(email, claimed_at)` for
|
||
the "is this email already invited?" pre-check; and
|
||
`(created_by_admin_id, created_at DESC)` for the per-admin
|
||
invites listing. Slot 018 is reserved for the parallel #12
|
||
release shipping in the same wave; slot 016 stays
|
||
reserved-and-skipped per Session K's v0.9.0 integration.
|
||
- **`frontend/src/components/InviteClaim.jsx`** — the
|
||
`/invites/claim?token=…` landing page. Reads the token from the
|
||
URL, renders a "Claim my account" CTA with an optional
|
||
"trust this device for 30 days" checkbox, calls
|
||
`POST /api/invites/claim` on submit, and routes onward
|
||
(`/settings/notifications#sign-in` if `needs_passcode`, else `/`)
|
||
on success. Anonymous-reachable.
|
||
- **"Create user + invite" affordance** on `/admin/users`
|
||
(`frontend/src/components/Admin.jsx`). A header button opens a
|
||
modal with email / first / last / role / custom-message inputs
|
||
(the textarea shows a "chars left" counter against the 500-char
|
||
ceiling). On submit, the modal calls
|
||
`POST /api/admin/users` and refreshes the user listing.
|
||
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
|
||
inline with v0.17.0): `USER_INVITED` event fires from
|
||
`CreateUserInviteModal` on successful invite-send with
|
||
`{ target_user_id, initial_role, custom_message_chars }` — the
|
||
OHM `invited_user_id` returned by `POST /api/admin/users`
|
||
becomes the dashboard's binding for the future Amplitude user
|
||
record; `custom_message_chars` is a coarse signal of admin
|
||
effort (0 = template-only, 1+ = personalized) and carries no
|
||
PII. `INVITE_CLAIMED` event fires from `InviteClaim.jsx` on
|
||
successful claim with `{ invited_by_admin_id, initial_role,
|
||
needs_passcode, trust_device }` — BUT the claim handler first
|
||
calls `identify({ user_id, properties: { claim_method:
|
||
'admin-invite', invited_at (setOnce), invited_by_admin_id
|
||
(setOnce), initial_role (setOnce) } })` so the Amplitude user
|
||
record is created with the OHM user_id from the very first
|
||
event the invitee fires, never as an anonymous device that
|
||
retroactively links. setOnce semantics preserve the original
|
||
invite context even if the invitee later changes roles.
|
||
- **"(pending invite)" badge** inline on the user-listing's
|
||
per-row handle (rendered when the row's `pending_invite` field
|
||
is populated by the backend's join through `user_invite_tokens`).
|
||
Clears automatically on claim as the invite row's `claimed_at`
|
||
populates.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api_admin.py`** — imports `invites` + `email_invite`
|
||
+ `EmailConfig`; adds the two new endpoints alongside the existing
|
||
`set_role` / `set_permission` neighbors; extends `list_users` to
|
||
join through `user_invite_tokens` and emit the `pending_invite`
|
||
field on each row. The new `CreateUserInviteBody` pydantic model
|
||
carries the body bounds (320-char email, 120-char first/last,
|
||
regex-pinned role, 500-char custom_message) so malformed input
|
||
fails at the body bound (422) instead of at the SQL layer.
|
||
- **`backend/app/main.py`** — imports `invites as invites_mod`;
|
||
adds the `InviteClaimBody` pydantic model alongside
|
||
`PasscodeVerifyBody`; mounts the `POST /api/invites/claim`
|
||
endpoint in the OAuth router so it can reuse the
|
||
`_set_device_trust_cookie` helper.
|
||
- **`frontend/src/api.js`** — exports `createUserInvite()`,
|
||
`listUserInvites()`, `claimInvite()`. Same fetch shape as the
|
||
rest of the v0.9.0 / v0.10.0 admin neighborhood.
|
||
- **`frontend/src/App.jsx`** — imports `InviteClaim`; registers the
|
||
`/invites/claim` route alongside `/beta-pending` (both are
|
||
anonymous-reachable auth-shape landings).
|
||
|
||
### Migration
|
||
|
||
- **`backend/migrations/019_user_invite_tokens.sql`** — auto-applied
|
||
on next backend start. Single new table with three indexes; no
|
||
changes to existing tables.
|
||
|
||
### Upgrade steps (from 0.14.0)
|
||
|
||
- You **MUST** apply schema migration `019_user_invite_tokens.sql`.
|
||
The migration creates a single new table with three indexes; the
|
||
framework runs migrations automatically at process start, so no
|
||
manual step is required beyond restarting the backend so the
|
||
migration runner picks the file up.
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading. `frontend/package.json#version` and `VERSION` both
|
||
move to `0.17.0`. No new env vars; no new secrets (the invite
|
||
email rides the existing SMTP relay configured for v0.7.0's OTC
|
||
mail).
|
||
- You **MAY** announce the new admin-create gesture to existing
|
||
admins. Existing user rows are unaffected — the
|
||
`user_invite_tokens` table is empty post-migration, and the
|
||
user-listing's new `pending_invite` field is null on every
|
||
existing row. The bootstrap shape for the very first admin
|
||
account stays the v0.9.0 path (DB-level role flip on an OTC-
|
||
provisioned row); the v0.17.0 admin-create gesture works
|
||
end-to-end once at least one admin exists.
|
||
|
||
|
||
## 0.16.0 — 2026-05-28
|
||
|
||
**Minor — schema migration auto-applied; no operator action.** This
|
||
release lands the owner-only invite for per-RFC PR or PR-less
|
||
discussion (roadmap item #12). The RFC's owner can now invite
|
||
specific users by email to one of two per-RFC roles —
|
||
`contributor` (open PRs against the RFC AND join its discussion) or
|
||
`discussant` (join the discussion only). Non-invited users keep
|
||
the v0.6.0 anonymous-read contract: they can read but cannot
|
||
write/discuss that RFC. Invitations are token-encoded in a
|
||
transactional email; acceptance lands a per-RFC collaborator row
|
||
and surfaces in the admin user-management page (additive on the
|
||
existing `/api/admin/users` shape) so the platform-grant decision
|
||
has the per-RFC context to inform it. The platform-level grant
|
||
remains the admin's call — this release adds a per-RFC membership
|
||
layer beneath it, not a new platform-grant path.
|
||
|
||
The per-RFC write gate is layered on top of the existing
|
||
`require_contributor` (v0.8.0) gate, not in place of it: a user
|
||
must be platform-granted AND hold an accepted per-RFC role (or
|
||
be the RFC owner / a platform admin/owner) to write. A super-
|
||
draft with no frontmatter owners yet (pre-§13.1 claim) falls
|
||
through to the platform-granted contract — there's no owner to
|
||
issue invitations, so the gate is open until one exists. This
|
||
preserves the v0.6.0 / v0.7.0 / v0.8.0 contracts inside their
|
||
domains and confines item #12's change to "an RFC has owners →
|
||
those owners decide who writes."
|
||
|
||
### Added
|
||
|
||
- **`backend/migrations/018_rfc_invitations.sql`** — two tables.
|
||
`rfc_invitations` carries the lifecycle row (issued, accepted,
|
||
revoked, expired) with the opaque token the email link encodes,
|
||
the inviter, the invitee email, the role-in-RFC, and the 30-day
|
||
expiry. `rfc_collaborators` is the accepted-invitation
|
||
substrate — the compact (rfc, user, role) shape the write gate
|
||
consults. Both tables are FK-cascaded against `cached_rfcs` and
|
||
`users` per §5's cascade rules. Indexed for the owner's listing,
|
||
the accept-by-token lookup, and the per-user read.
|
||
- **`backend/app/api_invitations.py`** — the §17 surface. Five
|
||
endpoints: `POST /api/rfcs/{slug}/invitations` (create + email),
|
||
`GET /api/rfcs/{slug}/invitations` (owner's listing),
|
||
`POST /api/rfcs/{slug}/invitations/{id}/revoke`,
|
||
`GET /api/invitations/accept?token=…` (preview), and
|
||
`POST /api/invitations/accept` (redeem). The email reuses
|
||
`EmailConfig.from_env()` and the `_SENT` buffer the OTC and
|
||
notification mailers share — transactional, no preferences
|
||
honored, no unsubscribe footer. A failure to send does NOT
|
||
roll back the row; the owner has the token on the listing
|
||
surface for an out-of-band share.
|
||
- **`backend/app/auth.py`** — four helpers. `is_rfc_owner`
|
||
reads the frontmatter `owners_json`. `is_rfc_collaborator`
|
||
reads the v0.16.0 `rfc_collaborators` table. `can_discuss_rfc`
|
||
and `can_contribute_to_rfc` are the composite predicates the
|
||
write endpoints consult (platform admin/owner OR no-owners-yet
|
||
fall-through OR RFC owner OR per-RFC collaborator at the right
|
||
role). `can_invite_to_rfc` is the issue-side predicate (RFC
|
||
owner or platform admin/owner only — collaborators don't get
|
||
invite power).
|
||
- **`frontend/src/components/InvitationsModal.jsx`** — the RFC
|
||
owner's surface: an email input + role picker for sending,
|
||
and a status table for listing/revoking. Visible only to the
|
||
RFC's owner or a platform admin/owner (the backend gates the
|
||
endpoints regardless).
|
||
- **`frontend/src/components/AcceptInvitation.jsx`** — the
|
||
`/invitations/accept?token=…` landing page. Previews what the
|
||
invitation grants, refuses on email mismatch / revoked /
|
||
expired with a single sentence each, redirects to the RFC's
|
||
view on accept.
|
||
- **API client (`frontend/src/api.js`)** — five new helpers:
|
||
`listRFCInvitations`, `createRFCInvitation`,
|
||
`revokeRFCInvitation`, `previewInvitation`, `acceptInvitation`.
|
||
- **Amplitude wiring** (per `ohm-rfc/ROADMAP.md` #21 Part C, shipped
|
||
inline with v0.16.0): `INVITATION_SENT` event fires from
|
||
`InvitationsModal.jsx` on successful send with `{ rfc_slug,
|
||
role_in_rfc }`; `INVITATION_ACCEPTED` event fires from
|
||
`AcceptInvitation.jsx` on successful accept with the same shape —
|
||
but the accept path first calls `identify({ user_id, properties:
|
||
{ invited_at (setOnce), last_invited_to_rfc,
|
||
last_invite_role_in_rfc, claim_method: 'rfc-invite' } })` so the
|
||
Amplitude user record carries the invite context from the moment
|
||
of acceptance. No invitee email or other PII enters the event
|
||
body — only the slug, role, and the inviter's identity (through
|
||
the standard signed-in identify on the inviter's session).
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api.py`** — registers
|
||
`api_invitations.make_router()` alongside the existing routers.
|
||
- **`backend/app/api_discussion.py`** — `POST .../discussion/threads`
|
||
and `POST .../discussion/threads/{thread_id}/messages` now compose
|
||
the new `auth.can_discuss_rfc` predicate after the existing
|
||
`require_contributor` check. A platform-granted user without a
|
||
per-RFC discussion role on an RFC with owners gets 403 with
|
||
"This RFC's owner has not invited you to its discussion."
|
||
- **`backend/app/api_branches.py`** — `POST .../promote-to-branch`
|
||
and `POST .../start-edit-branch` now compose
|
||
`auth.can_contribute_to_rfc`. Same shape: platform-granted but
|
||
uninvited → 403.
|
||
- **`backend/app/api_prs.py`** — `POST .../open-pr` also composes
|
||
`auth.can_contribute_to_rfc` so a user whose per-RFC role was
|
||
revoked between branch-cut and PR-open is refused at the
|
||
ship line.
|
||
- **`backend/app/api_admin.py`** — `GET /api/admin/users` carries
|
||
a new `rfc_invitations` array per user (empty if none), naming
|
||
each accepted per-RFC collaboration with the RFC slug/title,
|
||
the role, the inviter, and the timestamp. Additive — the
|
||
existing v0.9.0 columns are unchanged; consumers that don't
|
||
read the new field see the legacy shape.
|
||
- **`frontend/src/App.jsx`** — registers the
|
||
`/invitations/accept` route (visible to anonymous + signed-in
|
||
viewers; signed-out viewers see a sign-in prompt).
|
||
- **`frontend/src/components/RFCView.jsx`** — additive
|
||
"Invitations" button in the RFC header strip, visible to RFC
|
||
owners and platform admins/owners on both super-drafts and
|
||
active RFCs. Mounts the new modal on click.
|
||
- **`backend/tests/test_propose_vertical.py`** — adds the
|
||
`grant_rfc_collaborator` test helper so v0.5.0/v0.6.0/v0.8.0-era
|
||
tests that exercise non-owner contribution can opt into the new
|
||
invitation contract without rewriting their setup.
|
||
- **`backend/tests/test_pr_flow_vertical.py`,
|
||
`backend/tests/test_graduation_vertical.py`,
|
||
`backend/tests/test_e2e_smoke.py`** — three tests that signed in
|
||
as non-owner contributors now seed an accepted per-RFC
|
||
collaborator row first (mirroring the production invite→accept
|
||
dance). The test intent is unchanged; the precondition is now
|
||
explicit.
|
||
|
||
### Migration
|
||
|
||
- **`018_rfc_invitations.sql`** — auto-applied on backend start by
|
||
the existing `db.run_migrations()` sweep. The two new tables
|
||
are empty at upgrade time; no existing data is touched. No
|
||
operator gesture needed.
|
||
|
||
### Upgrade steps (from 0.15.0, or 0.14.0 if 0.15.0 is skipped)
|
||
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading so the new endpoints, the migration, the gate
|
||
composition in `api_discussion`/`api_branches`/`api_prs`, and the
|
||
new frontend routes/components are picked up. `frontend/package.json#version`
|
||
and `VERSION` both move to `0.16.0`.
|
||
- You **MUST NOT** set any new env var — there are no new secrets
|
||
and no new overlay keys. The email path reuses the existing
|
||
`SMTP_HOST` / `SMTP_PORT` / `SMTP_USER` / `SMTP_PASSWORD` /
|
||
`EMAIL_FROM` / `EMAIL_FROM_NAME` / `APP_URL` / `EMAIL_ENABLED`
|
||
variables that the v0.7.0 OTC and v0.5.0 notification paths
|
||
already require. Deployments that have those wired need no
|
||
configuration change.
|
||
- You **MUST NOT** apply the migration manually — the backend's
|
||
migration runner picks up `018_rfc_invitations.sql` on next
|
||
start. (If you've configured an external migration tool, run it
|
||
before starting the backend; the framework's own runner is
|
||
idempotent against already-applied migrations.)
|
||
- You **SHOULD** inform existing RFC owners that they can now
|
||
invite collaborators from the RFC view's header strip. RFCs
|
||
with frontmatter owners that pre-date this release see no
|
||
behavioral change for the owner; the change is visible to
|
||
non-owner contributors who previously could write on any RFC
|
||
and now must be invited first.
|
||
- You **MAY** seed `rfc_collaborators` rows directly via SQL for
|
||
pre-existing per-RFC working relationships you want to
|
||
grandfather past the v0.16.0 cutover. The `invitation_id`
|
||
column is nullable for exactly this purpose. Production
|
||
deployments without that history can ignore this option.
|
||
## 0.15.0 — 2026-05-28
|
||
|
||
**Minor — no schema migration; one new build-time env var bound via
|
||
`flotilla overlay set`.** This release ships Amplitude Analytics +
|
||
Session Replay instrumentation (roadmap item #13). The frontend
|
||
gains a small wrapper around `@amplitude/unified` that gates SDK
|
||
initialization on the v0.13.0 cookie/privacy consent — the SDK is
|
||
never loaded for visitors who have not granted analytics consent,
|
||
no session is recorded, no network request fires; a later consent
|
||
flip to `denied` calls `setOptOut(true)` so events and session
|
||
replay stop immediately. The wrapper exposes a stable taxonomy of
|
||
nine events (Page Viewed, RFC Viewed, User Signed In / Signed Out,
|
||
RFC Proposed, PR Opened, Comment Posted, Beta Access Requested, Admin
|
||
Permission Decision) wired into the existing routes, the Login flow,
|
||
the propose / open-PR / discussion / PR-review surfaces, and the
|
||
admin grant/revoke action. Event bodies carry only ids and enums; no
|
||
free-text fields (titles, comment bodies, names, emails) are ever
|
||
sent. **Session replay** records sessions at `sampleRate: 1` (100%)
|
||
— vendor-recommended default; gated by the same v0.13.0 analytics
|
||
consent. The Amplitude API key is read from `VITE_AMPLITUDE_API_KEY`
|
||
at build time; when unset, the wrapper logs one console warning and
|
||
no-ops so dev environments without analytics keep working. No backend
|
||
events ship in this release — Amplitude SaaS holds the events,
|
||
nothing lands in our DB, no migration.
|
||
|
||
### Added
|
||
|
||
- **Analytics wrapper** (`frontend/src/lib/analytics.js`). Public
|
||
surface: `track(name, props)`, `identify({ user_id, properties? })`,
|
||
`setUserProperties(properties)`, `anonymize()`, the `EVENTS`
|
||
taxonomy constant, and a `__resetForTests` helper. Internally
|
||
lazy-imports `@amplitude/unified` and calls
|
||
`amplitude.initAll(API_KEY, { analytics: { autocapture: true },
|
||
sessionReplay: { sampleRate: 1 } })` only after consent is
|
||
granted; queues pre-init calls and drains them on init resolve;
|
||
flips `setOptOut(true)` on a granted→denied consent change (stops
|
||
both analytics events and session replay). The wrapper subscribes
|
||
to `onConsentChange()` so a freshly-banner-clicked "analytics on"
|
||
flips the SDK live without a page reload.
|
||
- **User identity lifecycle** (per `ohm-rfc/ROADMAP.md` #21 Part C —
|
||
shipped inline with v0.15.0 instead of waiting for a follow-up).
|
||
`identify({ user_id, properties })` accepts a property bag that
|
||
applies as an Amplitude `Identify` event with `.set()` semantics
|
||
by default; values wrapped as `['__setOnce__', value]` apply with
|
||
`.setOnce()` semantics (immutable after first write — for
|
||
account-history markers like `first_sign_in_at`). The new
|
||
`setUserProperties(properties)` exposes the same property-apply
|
||
path for mid-session state changes (role grant/revoke, passcode
|
||
set, device trusted) so the Amplitude record stays current without
|
||
waiting for the next sign-in. `anonymize()` now clears both the
|
||
user_id binding AND the pending-property cache so a subsequent
|
||
sign-in as a different user starts with a fully fresh slate.
|
||
- **Event taxonomy** wired into the app:
|
||
- `Page Viewed` — fires from `App.jsx` on every route change with
|
||
`path` (`location.pathname`); the location hook owns the firing
|
||
and dedupes by path.
|
||
- `RFC Viewed` — fires from `RFCView.jsx` once per slug load with
|
||
`rfc_slug` and `rfc_id`.
|
||
- `User Signed In` — fires from `Login.jsx` with
|
||
`method ∈ { 'otc', 'passcode', 'trust-device' }` matching the
|
||
three sign-in paths from v0.7.0 / v0.10.0 / v0.11.0.
|
||
- `User Signed Out` — fires from `App.jsx`'s "Sign out" click,
|
||
followed by `anonymize()` to clear the SDK's user binding before
|
||
the hard nav to `/auth/logout`.
|
||
- `RFC Proposed` — fires from `ProposeModal.jsx` on submit success
|
||
with `rfc_slug`.
|
||
- `PR Opened` — fires from `PRModal.jsx` on submit success with
|
||
`rfc_slug` and `pr_number`.
|
||
- `Comment Posted` — fires from `RFCDiscussionPanel.jsx`
|
||
(`surface: 'discussion'`) and from `PRView.jsx`
|
||
(`surface: 'pr'`, with `pr_number`) on each post-success.
|
||
- `Beta Access Requested` — fires from `Login.jsx` capture-profile
|
||
submit success. No PII in the event.
|
||
- `Admin Permission Decision` — fires from `Admin.jsx`'s grant /
|
||
revoke action with `action ∈ { 'grant', 'revoke' }` and
|
||
`target_user_id` (string).
|
||
- **User binding + properties** (`App.jsx`): when `me.authenticated`
|
||
lands and a user id is available, the wrapper's
|
||
`identify({ user_id, properties })` is called with
|
||
`String(viewer.id)` AND a durable property bag — `role`,
|
||
`permission_state`, `passcode_set`, `device_trusted` (mutable;
|
||
refresh each sign-in), plus `first_sign_in_at` and
|
||
`account_created_at` (setOnce — immutable user-history markers).
|
||
The sign-out gesture calls `anonymize()` before the nav. No email,
|
||
display name, gitea_login, or other PII is passed through the SDK —
|
||
Amplitude only sees opaque ids, enums, timestamps, booleans.
|
||
- **`@amplitude/unified`** dependency added to
|
||
`frontend/package.json` (analytics + session replay in one
|
||
install). Lockfile updated.
|
||
- **`VITE_AMPLITUDE_API_KEY`** documented in `frontend/.env.example`
|
||
with the secret-vs-overlay binding caveat (see below).
|
||
|
||
### Changed
|
||
|
||
- **`frontend/src/App.jsx`** — adds `useLocation` for the route-change
|
||
Page Viewed firing, a `lastUserIdRef` memo to call
|
||
`identify` once per signed-in viewer, and an `onClick` handler on
|
||
the "Sign out" link that fires `User Signed Out` + `anonymize()`
|
||
before the hard nav.
|
||
- **`frontend/src/components/Login.jsx`** — fires `User Signed In`
|
||
with the appropriate `method` at each of the three sign-in points
|
||
(trust-device cookie path, passcode verify success, OTC verify
|
||
success), and fires `Beta Access Requested` on capture-profile
|
||
submit success.
|
||
- **`frontend/src/components/ProposeModal.jsx`** — fires `RFC Proposed`
|
||
with `rfc_slug` on submit success.
|
||
- **`frontend/src/components/RFCView.jsx`** — fires `RFC Viewed`
|
||
inside the `getRFC` resolution so the event is keyed on the slug
|
||
param and includes the loaded `rfc_id`.
|
||
- **`frontend/src/components/PRModal.jsx`** — fires `PR Opened` with
|
||
`rfc_slug` and `pr_number` on submit success.
|
||
- **`frontend/src/components/RFCDiscussionPanel.jsx`** — fires
|
||
`Comment Posted` with `surface: 'discussion'` on send-success.
|
||
- **`frontend/src/components/PRView.jsx`** — fires `Comment Posted`
|
||
with `surface: 'pr'` and `pr_number` on review-comment success.
|
||
- **`frontend/src/components/Admin.jsx`** — fires
|
||
`Admin Permission Decision` on grant/revoke success.
|
||
|
||
### Migration
|
||
|
||
- **No schema migration.** Amplitude SaaS holds the events; the
|
||
framework's DB is unchanged. Migration slot **015** is unused by
|
||
this release and remains available for the next minor that needs a
|
||
schema bump.
|
||
|
||
### Caveat — overlay binding for `VITE_AMPLITUDE_API_KEY`
|
||
|
||
Amplitude browser API keys are embedded in the frontend bundle at
|
||
build time and visible to anyone with browser dev tools. They are
|
||
public by design — same nature as the v0.12.0
|
||
`VITE_TURNSTILE_SITE_KEY` (also public, also bundle-embedded,
|
||
explicitly contrasted with `CLOUDFLARE_TURNSTILE_SECRET` which is
|
||
the real secret-half of that pair). The Amplitude installation
|
||
guidance from the vendor shows the key inline as a literal string
|
||
argument to `initAll(…)`, confirming the public framing. This
|
||
release accordingly binds the value via `flotilla overlay set`,
|
||
not `flotilla secret set` — the env-var name is `VITE_AMPLITUDE_API_KEY`
|
||
(Vite-prefix convention, so the build picks it up directly without
|
||
an alias step).
|
||
|
||
(Roadmap row #13 originally said "new secret: AMPLITUDE_API_KEY";
|
||
that wording predated vendor consultation. Mid-Session-L the
|
||
operator provisioned the Amplitude project, surfaced the vendor's
|
||
recommended init prompt, and the binding settled as overlay. The
|
||
roadmap row will be updated to match when #13 ships.)
|
||
|
||
### Caveat — session replay scope and consent
|
||
|
||
This release enables Amplitude Session Replay at `sampleRate: 1`
|
||
(100% of sessions recorded for full-DOM playback). The vendor's
|
||
installation wizard recommends this default for new deployments —
|
||
maximum learning during the early phase. The v0.13.0 single
|
||
"analytics" consent toggle gates session replay together with
|
||
events, so no recording happens without explicit opt-in. A future
|
||
release **MAY** split this into a separate consent category for
|
||
session replay specifically (recording has a meaningfully larger
|
||
privacy footprint than event counters); §19.2 candidate.
|
||
|
||
### Upgrade steps (from 0.14.0)
|
||
|
||
- You **MUST** install the new frontend dependency before building:
|
||
`cd frontend && npm install` picks up `@amplitude/unified` from
|
||
the updated `frontend/package.json` and the refreshed
|
||
`package-lock.json`. The lockfile change is committed.
|
||
- You **MUST** rebuild the frontend after upgrading so the analytics
|
||
wrapper and its consent gate ship to viewers. `frontend/package.json#version`
|
||
and `VERSION` both move to `0.15.0`. No schema migration; the
|
||
backend is unchanged for this release.
|
||
- **MUST**: before deploying, the operator runs `/Users/benstull/projects/wiggleverse/ohm-rfc-app-flotilla/.venv/bin/ohm-rfc-app-flotilla overlay set ohm-rfc-app VITE_AMPLITUDE_API_KEY=<key>` to bind the Amplitude project's public API key. (Receiving the value in the conversation is fine — it's bundle-embedded by design, same as `VITE_TURNSTILE_SITE_KEY`.) The deploy **SHOULD NOT** proceed before this binding exists; if the binding is absent, the frontend's analytics wrapper no-ops with a console warning and the rest of the app continues to function — but no events or session replays are sent.
|
||
- You **MAY** leave `VITE_AMPLITUDE_API_KEY` unset in dev environments
|
||
— the wrapper detects the empty value and no-ops with a single
|
||
console warning. The app, the consent banner, and every other
|
||
surface keep working unchanged.
|
||
- You **SHOULD** verify after deploy that the Amplitude dashboard
|
||
receives events and a session replay when a consenting browser
|
||
exercises one of the taxonomy events (the easiest probe: open the
|
||
deployed site in an Incognito window, accept analytics on the
|
||
consent banner, navigate to an RFC, and watch the project's live
|
||
event stream + replay panel).
|
||
|
||
## 0.14.0 — 2026-05-28
|
||
|
||
**Minor — no operator action required; new optional env var.** This
|
||
release ships `DOCS.md` and the `/docs` route — a public-facing user
|
||
guide that translates `SPEC.md` into plain prose for readers,
|
||
proposers, and contributors. The originating need was the
|
||
admin-vs-owner distinction on the `/admin/users` surface (the §6.1
|
||
role separation was load-bearing but only documented in spec voice);
|
||
the response was a single guide that covers the framework's user-
|
||
facing surfaces end-to-end. Mirrors `/philosophy` end-to-end: a
|
||
markdown file checked into the repo root, served by a sibling backend
|
||
loader, rendered with `MarkdownPreview`. No schema migration. No
|
||
required env-var changes. The new "Docs" header link sits alongside
|
||
the persistent "About" link from §14.3 and is reachable by anonymous
|
||
viewers per the same v0.3.0 anonymous-read contract.
|
||
|
||
### Added
|
||
|
||
- **`DOCS.md`** at the repo root — the user-facing guide. Covers
|
||
reading anonymously, signing in, proposing an RFC, super-drafts vs
|
||
active RFCs, the discussion-vs-contribution distinction (§10.10),
|
||
working on a branch (contribute mode, AI proposals, manual edits,
|
||
flags, branch visibility, contribute grants, hygiene), opening and
|
||
reviewing PRs, graduation (§13), withdrawal and reopening, the AI
|
||
participant (§6.6 / §6.7 / §18), notifications and watch states
|
||
(§15), and the full roles-and-permissions story (§6 in plain
|
||
prose: anonymous / contributor / admin / owner, per-RFC
|
||
owners + arbiters, per-branch contribute grants, the write-mute,
|
||
and the three structurally distinct "mutes"). Framework-neutral —
|
||
no deployment-specific names or corpus references; consistent with
|
||
`CLAUDE.md`'s separation-of-concerns rule.
|
||
- **`backend/app/docs.py`** — sibling loader for `philosophy.py`.
|
||
Reads `DOCS.md` from the repo root with the same disk-first,
|
||
in-process-cached, `refresh()`-on-demand shape. Optional
|
||
`DOCS_PATH` env var points at an alternative source (e.g. a
|
||
meta-repo working-tree clone) for deployments that prefer that.
|
||
- **`§17` endpoint** — `GET /api/docs` returns
|
||
`{ "body": "<DOCS.md verbatim>" }`. Anonymous-reachable, same
|
||
contract as `GET /api/philosophy`.
|
||
- **`frontend/src/components/Docs.jsx`** — the `/docs` reading
|
||
surface. Mirrors `Philosophy.jsx`: chrome with Back / "USER GUIDE" /
|
||
Home affordances, body rendered through `MarkdownPreview`.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api.py`** — imports `docs as docs_mod` alongside
|
||
`philosophy` in the relative-import block; registers the new
|
||
`GET /api/docs` handler immediately after `GET /api/philosophy`.
|
||
- **`frontend/src/api.js`** — exports `getDocs()` alongside
|
||
`getPhilosophy()`. Same fetch shape, different endpoint path.
|
||
- **`frontend/src/App.jsx`** — imports `Docs` alongside `Philosophy`,
|
||
registers the `/docs` route alongside `/philosophy`, adds the
|
||
persistent "Docs" header link alongside "About", and adds the
|
||
`DocsWithSidebar` chrome wrapper alongside `PhilosophyWithSidebar`.
|
||
|
||
### Upgrade steps (from 0.13.0)
|
||
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading so the new `/docs` route, the new endpoint, and the new
|
||
loader are picked up. `frontend/package.json#version` and `VERSION`
|
||
both move to `0.14.0`. No schema migration; the new endpoint
|
||
serves a checked-in file.
|
||
- You **MAY** set `DOCS_PATH` to an absolute path if your deployment
|
||
hosts `DOCS.md` outside the framework's repo (e.g. as a sync target
|
||
from a content repo). Unset is supported — the framework's
|
||
`DOCS.md` at the repo root is the default, mirroring how
|
||
`PHILOSOPHY_PATH` works for `/api/philosophy`.
|
||
- You **MAY** customize `DOCS.md` for your deployment if you want
|
||
deployment-specific phrasing layered on top of the framework's
|
||
guide. The file is a regular markdown source; standard `vim`/`git`
|
||
edits suffice. Framework upgrades that ship a new `DOCS.md` will
|
||
show as a normal merge in your deployment-overlay layer.
|
||
|
||
## 0.13.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; new optional env vars.** This
|
||
release ships the cookie / privacy consent surface (roadmap item #11,
|
||
SPEC §14.5 / §14.6). Every viewer — authenticated and anonymous alike —
|
||
now sees a non-modal bottom-of-page banner on first visit asking which
|
||
categories of cookies they allow (essential / essential + analytics /
|
||
essential + analytics + other). The choice persists in `localStorage`
|
||
for anonymous viewers and in a new `cookie_consent` table for
|
||
authenticated viewers, with server-side overriding local on sign-in.
|
||
The framework also ships default `/privacy` and `/cookies` policy pages
|
||
that deployments can layer their own policy URL on top of via two new
|
||
optional env vars. No analytics SDK ships in this release — the
|
||
consent infrastructure is wired so item #13 (v0.15.0) can read from
|
||
`frontend/src/lib/consent.js` when the SDK lands.
|
||
|
||
### Added
|
||
|
||
- **Cookie consent banner** (`frontend/src/components/CookieConsentBanner.jsx`).
|
||
Non-modal, bottom of viewport. Three single-select choices with
|
||
inline descriptions. Visible until the user makes a choice; hides
|
||
thereafter. Reachable for revision via the settings surface.
|
||
- **Consent helper** (`frontend/src/lib/consent.js`). Exports
|
||
`getConsent()`, `hasChosen()`, `onConsentChange(cb)`, `setConsent()`,
|
||
`hydrateFromServer()`, `clearLocal()`. Cross-tab sync via the
|
||
`storage` event. Item #13's analytics SDK reads consent here before
|
||
importing.
|
||
- **Privacy and cookies policy pages**
|
||
(`frontend/src/pages/Privacy.jsx`, `frontend/src/pages/Cookies.jsx`).
|
||
Default minimal policies that describe the framework's stance and
|
||
list the cookies the framework sets. Deployments override via the
|
||
two new env vars below; the framework's stub always renders above
|
||
the link so the framework-level contract stays visible.
|
||
- **"Privacy & cookies" tab** in `/settings/notifications` showing
|
||
the current consent choice, the recorded-at stamp, and a "Change"
|
||
button that re-opens the banner via a custom DOM event.
|
||
- **`§17` endpoints** —
|
||
- `GET /api/users/me/cookie-consent` — read the current consent
|
||
record.
|
||
- `PUT /api/users/me/cookie-consent` — write a new consent record.
|
||
Upserts a single row per user, stamps `recorded_at` to now,
|
||
accepts `essential` for symmetry but always persists it as true.
|
||
- **Schema migration** `013_cookie_consent.sql` — new
|
||
`cookie_consent` table keyed by `user_id`, three flags
|
||
(`essential`, `analytics`, `other_cookies`), and `recorded_at`.
|
||
(Renumbered from `012_*` during driver integration because v0.7.0
|
||
also added a `012_otc.sql` migration that landed in the integration
|
||
order before this one.)
|
||
- **SPEC `§14.5` Cookie / privacy consent** — settles the banner
|
||
shape, the three-category single-select, the storage shape (local
|
||
for anon, server row for authenticated), the precedence rule on
|
||
sign-in, and the `consent.js` helper surface for downstream
|
||
callers including item #13.
|
||
- **SPEC `§14.6` Privacy and cookies policy pages** — settles the
|
||
`/privacy` and `/cookies` routes, the framework's stub content, and
|
||
the `VITE_PRIVACY_POLICY_URL` / `VITE_COOKIES_POLICY_URL` override
|
||
shape.
|
||
- **SPEC `§5`** — names the `cookie_consent` table in the canonical
|
||
app-tables list.
|
||
- **SPEC `§17`** — lists the two new cookie-consent endpoints.
|
||
- **SPEC `§19.2`** — surfaces four candidates: policy content via
|
||
content-repo file vs env var, GPC / DNT headers, multi-language
|
||
consent text, and the item #13 analytics-SDK gating dependency.
|
||
|
||
### Changed
|
||
|
||
- **`frontend/.env.example`** — documents the two new optional env
|
||
vars `VITE_PRIVACY_POLICY_URL` and `VITE_COOKIES_POLICY_URL`. Unset
|
||
is supported; defaults render the framework's stub.
|
||
- **`backend/app/api_notifications.py`** — module docstring grew two
|
||
endpoint lines; the new endpoints sit alongside the existing
|
||
`/api/users/me/*` neighbors.
|
||
- **`frontend/src/App.jsx`** — registers `/privacy` and `/cookies`
|
||
routes (anonymous-reachable), wires `<CookieConsentBanner>` into
|
||
the global chrome, and listens for a `rfc-app:cookie-consent-reopen`
|
||
custom event to re-open the banner from the settings surface.
|
||
|
||
### Upgrade steps (from 0.7.0)
|
||
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading. `frontend/package.json#version` and `VERSION` both move
|
||
to `0.13.0` and the build embeds the new env-var contract.
|
||
- You **MUST** apply schema migration `013_cookie_consent.sql`. The
|
||
migration creates a single new table keyed by `user_id` with three
|
||
flag columns and a `recorded_at` stamp. The framework runs
|
||
migrations automatically at process start; no manual step is
|
||
required beyond restarting the backend so the migration runner
|
||
picks the file up.
|
||
- You **MAY** set `VITE_PRIVACY_POLICY_URL` to an http(s) URL that
|
||
points at your deployment's full privacy policy. The framework's
|
||
`/privacy` page renders its built-in stub above a link to the
|
||
configured URL. Unset is supported — the stub is sufficient for a
|
||
default-config deployment.
|
||
- You **MAY** set `VITE_COOKIES_POLICY_URL` to an http(s) URL that
|
||
points at your deployment's full cookies policy. Same shape as the
|
||
privacy URL.
|
||
- You **MAY** announce the new consent banner to your users. Existing
|
||
authenticated users will see the banner on their next visit
|
||
(because their `cookie_consent` row does not yet exist); their
|
||
current sessions remain valid.
|
||
|
||
## 0.12.0 — 2026-05-28
|
||
|
||
**Minor — operator action required (new secret + new overlay).**
|
||
CloudFlare Turnstile gates the email-entry step of the OTC sign-in
|
||
flow against automated abuse (roadmap item #10, SPEC §6.2 / §19.2-
|
||
settled). Since v0.7.0 made `/auth/otc/request` the primary human-
|
||
auth path and v0.8.0 opened the request endpoint to any valid email,
|
||
the OTC dispatch became the natural target for distributed scrapers
|
||
fanning out to harvest "this email is admitted vs. this email is
|
||
not" timing/bounce signals. The per-email cooldown stops the trivial
|
||
back-to-back loop; the Turnstile challenge stops the distributed one
|
||
by costing the attacker a browser-side proof-of-humanness on every
|
||
request. The challenge runs before the bcrypt hash + SMTP send so a
|
||
failed verify spends no rate budget and produces no envelope.
|
||
|
||
Scope: the widget renders on the email-entry step of `/login` only.
|
||
The OTC verify step (where the user pastes the six-digit code) is
|
||
already bottlenecked on email delivery and protected by the
|
||
five-minute TTL + single-use consume on the row; a second challenge
|
||
there would double the rate budget against the same abuse path
|
||
without measurably more protection. If bots adapt to defeat the
|
||
email-entry challenge specifically — pushing the abuse vector onto
|
||
the verify step — a future release adds the second widget. The
|
||
widget also renders on the passcode step's "Use a code instead"
|
||
fallback dispatch since that route also calls `/auth/otc/request`.
|
||
|
||
Default policy: `TURNSTILE_REQUIRED=false`. The gate stays open when
|
||
the secret is absent — the dev / test path, and the pre-rollout
|
||
path while the operator is wiring the secret. Once the secret is in
|
||
GCP Secret Manager and the site key is in the overlay, the operator
|
||
**MAY** flip `TURNSTILE_REQUIRED=true` so a future config drift on
|
||
the secret fails loudly (HTTP 500 "auth misconfigured") instead of
|
||
silently disabling abuse defense.
|
||
|
||
No schema migration — Turnstile siteverify is stateless.
|
||
|
||
### Added
|
||
|
||
- **`backend/app/turnstile.py`** — the siteverify caller. POSTs
|
||
`secret` + `response` (+ optional `remoteip`) to
|
||
`https://challenges.cloudflare.com/turnstile/v0/siteverify` and
|
||
returns a `VerifyOutcome` (`ok` boolean + `reason` enum:
|
||
`ok` / `skipped` / `misconfigured` / `missing-token` / `failed` /
|
||
`network`). Tunables read from env at call time so tests
|
||
monkeypatch cleanly: `CLOUDFLARE_TURNSTILE_SECRET`,
|
||
`TURNSTILE_REQUIRED`, and (test-only) `TURNSTILE_SITEVERIFY_URL`.
|
||
- **`frontend/src/components/TurnstileWidget.jsx`** — the React
|
||
wrapper around the official CloudFlare Turnstile JS API. Reads the
|
||
site key from `import.meta.env.VITE_TURNSTILE_SITE_KEY`; renders
|
||
nothing when the var is unset (the form still submits and the
|
||
backend's `TURNSTILE_REQUIRED` policy decides admission). Loads
|
||
the CloudFlare script once per page on first widget mount. Cleans
|
||
up the widget instance on unmount via `turnstile.remove()` so a
|
||
remount produces a fresh challenge rather than reusing a stale,
|
||
already-consumed token.
|
||
- **Backend tests** (`backend/tests/test_turnstile_vertical.py`) —
|
||
five vertical scenarios: happy path (secret + valid token →
|
||
admit), siteverify rejects → 400 + no envelope, missing-token →
|
||
400 + no envelope, missing-secret-soft (default) → admit, and
|
||
missing-secret-hard (`TURNSTILE_REQUIRED=true`) → 500
|
||
"misconfigured". All five mock the siteverify HTTP call via
|
||
`monkeypatch.setattr(turnstile.httpx, "post", …)`; no real
|
||
CloudFlare keys are ever embedded.
|
||
- **SPEC `§6.2`** — names the Turnstile gate on the OTC dispatch as
|
||
the v0.12.0 settled shape; the §19.2 candidate from v0.7.0 closes.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/main.py`** — `OtcRequestBody` grows an optional
|
||
`turnstile_token` field. The `/auth/otc/request` handler calls
|
||
`turnstile.verify_token` first, before `otc.request_code`, so a
|
||
failed challenge spends no rate budget and produces no envelope.
|
||
The handler maps `misconfigured` → HTTP 500, all other failures
|
||
(`missing-token`, `failed`, `network`) → uniform HTTP 400 so the
|
||
response does not enumerate which leg of the challenge broke.
|
||
- **`frontend/src/api.js`** — `requestOtc` accepts a second arg
|
||
`{ turnstileToken }` and threads it into the request body. The
|
||
positional signature stays backwards-compatible so calls that pass
|
||
only an email still type-check.
|
||
- **`frontend/src/components/Login.jsx`** — the email step and the
|
||
passcode step both render `<TurnstileWidget>`. The submit button
|
||
on the email step is disabled until the widget produces a token
|
||
(when the widget is enabled at build time); the "Use a code
|
||
instead" link on the passcode step has the same gate. A 400 from
|
||
`/auth/otc/request` clears the token and surfaces a "couldn't
|
||
verify you're human, please retry" status. The fallback-from-
|
||
passcode path bounces back to the email step on 400 so the user
|
||
gets a fresh challenge in the natural place.
|
||
- **`backend/.env.example`** — documents `CLOUDFLARE_TURNSTILE_SECRET`
|
||
and `TURNSTILE_REQUIRED` alongside the existing OTC tunables.
|
||
- **`frontend/.env.example`** — documents `VITE_TURNSTILE_SITE_KEY`
|
||
with the operator wire-up procedure (dash.cloudflare.com →
|
||
Turnstile → Add site).
|
||
|
||
### Upgrade steps (from 0.10.0)
|
||
|
||
The operator **MUST** create a CloudFlare Turnstile site
|
||
(dash.cloudflare.com → Turnstile → Add site, choose "Managed" widget
|
||
mode), obtain the site key (public) and secret key (private), and:
|
||
|
||
- You **MUST** `flotilla secret set ohm-rfc-app CLOUDFLARE_TURNSTILE_SECRET`
|
||
(paste the secret key when prompted) before the v0.12.0 deploy.
|
||
The framework reads the secret at request time; deploying v0.12.0
|
||
without the secret leaves the gate in its default soft-fail state
|
||
(every request admitted regardless of token), which means abuse
|
||
defense is silently off.
|
||
- You **MUST** `flotilla overlay set ohm-rfc-app VITE_TURNSTILE_SITE_KEY <site-key>`
|
||
so the frontend build embeds the site key and the widget renders
|
||
on `/login`. The site key is public — it travels in the bundle and
|
||
appears in every browser — so this is the overlay (non-secret)
|
||
layer per the §3 invariant 1 split. Skipping this step leaves
|
||
`/login` with no widget; even after the operator sets the secret,
|
||
the backend would refuse every request as `missing-token` once
|
||
`TURNSTILE_REQUIRED=true` flips.
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading. `frontend/package.json#version` and `VERSION` both move
|
||
to `0.12.0`. No schema migration; Turnstile siteverify is
|
||
stateless. The site-key embed is build-time, so the rebuild after
|
||
the `flotilla overlay set` is what actually wires the widget into
|
||
the bundle the deploy serves.
|
||
- You **MAY** `flotilla overlay set ohm-rfc-app TURNSTILE_REQUIRED true`
|
||
once you've confirmed a real sign-in works end-to-end with the
|
||
widget. The default (`false`) keeps the gate in soft-fail mode so
|
||
a missing-secret regression admits requests rather than 500ing
|
||
every sign-in attempt; flipping to `true` makes a future config
|
||
drift on the secret fail loudly with HTTP 500 instead of silently
|
||
disabling abuse defense. The framework's tested path is the
|
||
flipped-to-true production shape; the default `false` exists for
|
||
the dev / pre-rollout window only.
|
||
- You **MAY** customize the Turnstile widget mode (Managed /
|
||
Non-interactive / Invisible) from the dashboard at any time
|
||
without redeploying — the site key stays the same, and the widget
|
||
picks up the mode change on the next page load. The framework's
|
||
tested path is "Managed" because it gives the operator a visible
|
||
challenge surface to debug against.
|
||
|
||
If either of the two **MUST** secret/overlay steps is skipped, the
|
||
deploy still boots and `/login` still serves; the failure mode is
|
||
that abuse defense is off (default `TURNSTILE_REQUIRED=false`) or
|
||
every sign-in attempt 500s (`TURNSTILE_REQUIRED=true` flipped while
|
||
the secret is unset). The driver pauses the wave at the secret/
|
||
overlay gesture so the operator confirms both are in place before
|
||
the framework version pin moves.
|
||
|
||
## 0.11.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; no new env vars.** This release
|
||
ships the "trust this device for 30 days" gesture (roadmap item #9,
|
||
SPEC §6.2). After a successful OTC or passcode sign-in, the user
|
||
can check a single checkbox to mint a server-issued opaque
|
||
device-trust token; the token rides as a long-lived HttpOnly +
|
||
Secure + SameSite=Lax cookie, and the matching row's hash lives in a
|
||
new `device_trust` table. On a subsequent visit, the cookie is
|
||
presented at `POST /auth/device-trust/start` — if a non-expired,
|
||
non-revoked row matches, the session is re-established without
|
||
another OTC / passcode roundtrip. A new `/settings/notifications`
|
||
"Trusted devices" section lists active rows (created-at, last-seen,
|
||
expiry, rough UA label) with per-row "Revoke" and a "Revoke all
|
||
devices" button. The cookie is "essential" per the v0.13.0 cookie-
|
||
consent contract — it is part of authentication, not analytics — and
|
||
is set regardless of the user's analytics / other-cookies choice.
|
||
|
||
The session model gains a cookie, not a session-store change: the
|
||
existing `rfc_session` cookie still carries the in-flight session
|
||
state; the new `rfc_device_trust` cookie is consulted only by
|
||
`/auth/device-trust/start` to bootstrap a fresh session on a return
|
||
visit. The raw token only ever lives in the outbound `Set-Cookie`
|
||
header and the inbound `Cookie` header; server-side storage is the
|
||
bcrypt hash; constant-time comparison via `bcrypt.checkpw` on the
|
||
candidate walk. The raw token is never logged.
|
||
|
||
### Added
|
||
|
||
- **`device_trust` table** (`backend/migrations/017_device_trust.sql`).
|
||
Per-row id, `user_id` (FK with cascade), `device_token_hash`
|
||
(bcrypt at rest, unique index documents the no-collision
|
||
invariant), `created_at`, `expires_at` (`created_at + 30 days`),
|
||
`user_agent` (verbatim, app-layer-truncated to 1024 chars),
|
||
`last_seen_at` (refreshed on every successful lookup), `revoked_at`
|
||
(NULL means active). Secondary index on `(user_id, revoked_at)` so
|
||
the /settings list query is a covering walk.
|
||
- **`backend/app/device_trust.py`** — sibling of `otc.py` and
|
||
`passcode.py`. Carries `issue(user_id, user_agent)`,
|
||
`lookup(raw_token)`, `list_for_user(user_id)`, `revoke(user_id,
|
||
row_id)`, and `revoke_all(user_id)`. The 30-day window and the
|
||
cookie name (`rfc_device_trust`) live as module-level constants;
|
||
env-ifying them is a §19.2 candidate.
|
||
- **`§17` endpoints**
|
||
- `POST /auth/device-trust/start` — anonymous-reachable. Reads the
|
||
`rfc_device_trust` cookie; on a hit, signs the user in. On a
|
||
miss (expired, revoked, or unknown), clears the stale cookie and
|
||
returns 401.
|
||
- `GET /api/auth/me/devices` — list active trusted devices for
|
||
the signed-in user.
|
||
- `DELETE /api/auth/me/devices/{id}` — revoke a single row.
|
||
User-id scope enforced in SQL so a hostile client cannot
|
||
revoke another user's row by guessing ids.
|
||
- `DELETE /api/auth/me/devices` — revoke every active row.
|
||
- **OTC and passcode verify bodies** gain an optional
|
||
`trust_device: bool` field (default false). When true and verify
|
||
succeeds, the endpoint mints a fresh device-trust row and sets
|
||
the cookie on the response. Pre-v0.11.0 clients that omit the
|
||
field continue to behave as before.
|
||
- **Login.jsx** gains a "Trust this device for 30 days" checkbox
|
||
on both the OTC and passcode verify steps, plus a silent on-mount
|
||
call to `POST /auth/device-trust/start` so a returning user with
|
||
a valid cookie skips the email step entirely. A failure is
|
||
intentionally invisible — the user proceeds to the normal email
|
||
step.
|
||
- **`/settings/notifications` "Trusted devices" section** — lists
|
||
active rows with per-row "Revoke" + a "Revoke all devices" button
|
||
(with a `confirm()` prompt because the gesture is broad). The
|
||
surface intentionally does not single out the row whose cookie
|
||
the current request carries so a user can revoke "this device"
|
||
alongside any other from one place.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/main.py`** — the OTC and passcode verify endpoints
|
||
now also accept the `trust_device` flag and accept an injected
|
||
`Response` so they can attach the cookie. Two helpers
|
||
(`_set_device_trust_cookie`, `_clear_device_trust_cookie`) carry
|
||
the cookie attribute set in one place so the contract is
|
||
consistent across endpoints. The `Response` import is added
|
||
alongside the existing FastAPI re-exports.
|
||
- **`backend/app/api.py`** — imports `device_trust as device_trust_mod`
|
||
alongside `auth`/`db`; mounts the three `/api/auth/me/devices*`
|
||
endpoints immediately after `/api/auth/me/beta-request` so the
|
||
auth-shaped neighborhood stays clustered.
|
||
- **`frontend/src/api.js`** — exports `startDeviceTrust()`,
|
||
`listMyDevices()`, `revokeMyDevice(id)`, `revokeAllMyDevices()`.
|
||
`verifyOtc` and `verifyPasscode` accept an optional
|
||
`{ trustDevice }` argument that rides on the POST body.
|
||
|
||
### Upgrade steps (from 0.10.0)
|
||
|
||
- You **MUST** apply schema migration `017_device_trust.sql`. The
|
||
migration creates a single new table with one secondary index;
|
||
the framework runs migrations automatically at process start, so
|
||
no manual step is required beyond restarting the backend so the
|
||
migration runner picks the file up.
|
||
- You **MUST** rebuild the frontend and restart the backend after
|
||
upgrading. `frontend/package.json#version` and `VERSION` both
|
||
move to `0.11.0` and the new `Set-Cookie` shape requires the
|
||
backend to be on the matching version.
|
||
- You **MUST** serve the deployment over HTTPS. The
|
||
`rfc_device_trust` cookie is set with `Secure=True` — a
|
||
cleartext deployment will never receive the cookie back from
|
||
the browser, so the trust gesture will appear to silently fail.
|
||
Production OHM deployments already serve over HTTPS; local
|
||
development against `http://localhost` is unaffected (no cookie
|
||
is set, the OTC/passcode paths continue to work).
|
||
- You **MAY** announce the new feature to your users. Existing
|
||
signed-in sessions are unaffected — the device-trust cookie is
|
||
opt-in on the next sign-in, and a user who never checks the box
|
||
keeps the v0.10.0 behavior verbatim.
|
||
|
||
|
||
## 0.10.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; new auth path is additive.**
|
||
This release lands user-set passcodes after OTC (roadmap item #8,
|
||
SPEC §6.2). After a successful one-time-code sign-in, the user can
|
||
set a passcode (4–20 characters) and use email + passcode for
|
||
subsequent sign-ins. OTC remains the structural fallback: a
|
||
forgotten passcode is recovered by requesting a fresh code, and
|
||
five consecutive failed passcode verifies lock the passcode path
|
||
for 15 minutes (HTTP 423) while leaving the OTC path open. The
|
||
`/login` surface now consults a new `GET /auth/passcode/check`
|
||
endpoint after the email step to decide whether to render a
|
||
passcode input or an OTC code input; an "Use a code instead" link
|
||
on the passcode step lets the user fall back to OTC manually. The
|
||
`/settings/notifications` page grew a new "Sign-in" tab where the
|
||
user can set, change, or remove their passcode.
|
||
|
||
### Upgrade steps (from 0.8.0)
|
||
|
||
1. **MUST** restart the backend so migration `015_passcode.sql`
|
||
runs. The migration adds four nullable columns to the `users`
|
||
table: `passcode_hash`, `passcode_set_at`,
|
||
`passcode_failed_attempts` (default 0), `passcode_locked_until`.
|
||
Existing rows pass through with `passcode_hash = NULL`, which
|
||
the runtime treats as "no passcode set" — every existing user
|
||
continues to sign in via OTC unchanged, and can opt into a
|
||
passcode from the new settings tab at any time.
|
||
2. **MUST** rebuild the frontend so the v0.10.0 `/login` flow and
|
||
the new settings tab ship. `frontend/package.json#version` and
|
||
`VERSION` both move to `0.10.0`.
|
||
3. **SHOULD** announce the new sign-in option to users. Wording
|
||
suggestion: "You can now set a passcode for faster sign-in.
|
||
We'll keep emailing one-time codes as a fallback — if you
|
||
forget your passcode, just request a code as usual."
|
||
4. **MAY** leave the §6.2 default lockout shape (5 attempts,
|
||
15-minute window) unchanged. v0.10.0 does not expose env
|
||
tunables for these; raising or lowering them lives in §19.2
|
||
as a candidate.
|
||
|
||
### Added
|
||
|
||
- **`POST /auth/passcode/set`** — authenticated. Body `{passcode}`.
|
||
Validates length (4–20) and refuses obvious patterns from a small
|
||
denylist (`0000`, `1234`, `aaaa`, `password`, etc.). bcrypt-hashes
|
||
the passcode and writes `users.passcode_hash` plus
|
||
`users.passcode_set_at`. Clears any active lockout and the
|
||
failure counter (a user setting a fresh passcode is implicitly
|
||
re-authenticating). Replaces any prior passcode.
|
||
- **`DELETE /auth/passcode`** — authenticated. Clears the passcode
|
||
hash and the set-at stamp; the user is back to OTC-only.
|
||
- **`POST /auth/passcode/verify`** — unauthenticated. Body
|
||
`{email, passcode}`. Returns HTTP 200 + minimal user payload on
|
||
success; HTTP 423 with `locked_until` when the account is in the
|
||
lockout window; HTTP 400 for every other failure (the
|
||
wrong-passcode and unknown-email modes both collapse to 400 so
|
||
the response does not enumerate account state).
|
||
- **`GET /auth/passcode/check`** — unauthenticated. Query param
|
||
`email`. Returns `{has_passcode: boolean}`. The Login.jsx flow
|
||
consults this after the email step to decide whether to render a
|
||
passcode input or fall back to OTC. The response carries only
|
||
the boolean; lockout state, the hash, and the `passcode_set_at`
|
||
stamp are not leaked. An unknown email and a known-without-
|
||
passcode email both return `false`, so the endpoint is
|
||
account-enumeration-safe.
|
||
- **Schema migration** `015_passcode.sql` — four ALTER TABLE ADD
|
||
COLUMN statements on the `users` table:
|
||
- `passcode_hash TEXT` (nullable) — the bcrypt hash. NULL means
|
||
"no passcode set".
|
||
- `passcode_set_at TEXT` (nullable) — ISO-8601 timestamp.
|
||
- `passcode_failed_attempts INTEGER NOT NULL DEFAULT 0` —
|
||
consecutive failure counter since last success.
|
||
- `passcode_locked_until TEXT` (nullable) — lockout window
|
||
expiry; verify refuses with HTTP 423 while populated and
|
||
in the future.
|
||
- **`backend/app/passcode.py`** — the passcode state machine:
|
||
validation (length + denylist), bcrypt hashing, set/clear,
|
||
status check, and the verify path with lockout management.
|
||
- **`frontend/src/components/Login.jsx`** — extended to a five-step
|
||
surface: email → passcode-or-code → optional post-OTC
|
||
passcode-offer → optional set-passcode. The "Use a code instead"
|
||
link on the passcode step re-dispatches an OTC and switches to
|
||
the code step. A 423 from passcode verify auto-falls back to OTC
|
||
with a visible status message.
|
||
- **"Sign-in" tab** in `/settings/notifications` — shows
|
||
passcode-set status, the recorded `passcode_set_at` stamp when
|
||
set, and Set / Change / Remove buttons. Mirrors the §14.5
|
||
"Privacy & cookies" tab pattern.
|
||
- **SPEC `§6` / `§14.1` / `§17` / `§19.2`** corrections per §19.3
|
||
rule 2:
|
||
- §6 names the three current auth paths (OTC, passcode-with-OTC-
|
||
fallback, OAuth-fallback-during-migration).
|
||
- §14.1 documents the stepped `/login` surface and the passcode
|
||
check endpoint.
|
||
- §17 lists the four new `/auth/passcode/*` endpoints.
|
||
- §19.2 surfaces four new candidates (passcode policy tunables
|
||
via env, per-IP rate-limit on `/auth/passcode/verify`,
|
||
passcode-change "old passcode" challenge, passkey/WebAuthn);
|
||
the "device-trust 30d" entry's passcode cross-ref is updated;
|
||
the "first-OTC profile capture" entry's roadmap cross-ref is
|
||
updated.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/main.py`** — registers the four new
|
||
`/auth/passcode/*` routes on the existing oauth router, alongside
|
||
the v0.7.0 `/auth/otc/*` routes.
|
||
- **`backend/app/api.py`** — `/api/auth/me` payload now includes
|
||
`has_passcode` (boolean) and `passcode_set_at` (string or null)
|
||
so the settings surface can render the Set / Change / Remove
|
||
affordances without a second round trip.
|
||
- **`frontend/src/api.js`** — adds `checkPasscode`, `verifyPasscode`,
|
||
`setPasscode`, `clearPasscode` helpers, neighboring the v0.7.0
|
||
`requestOtc` / `verifyOtc` block.
|
||
- **`frontend/src/components/NotificationSettings.jsx`** — adds the
|
||
`SignInSection` component between `MutesSection` and
|
||
`PrivacyCookiesSection`.
|
||
|
||
### Tests
|
||
|
||
- **`backend/tests/test_passcode_vertical.py`** — 17 new tests
|
||
cover: set requires session; check returns false for unknown and
|
||
for set-less users; check returns true after set without leaking
|
||
other fields; happy-path OTC → set → verify roundtrip; wrong
|
||
passcode increments the counter without locking; five consecutive
|
||
failures lock with 423 and persist `passcode_locked_until`;
|
||
lockout expires and the next attempt clears the counter; the OTC
|
||
path is unaffected by passcode lockout; clear wipes the hash and
|
||
set-at; setting a new passcode replaces the prior one and resets
|
||
the lockout; `passcode_set_at` updates on every set; validation
|
||
refuses too-short passcodes and denylist patterns; `/api/auth/me`
|
||
carries `has_passcode` and `passcode_set_at` correctly.
|
||
|
||
### Environment variables
|
||
|
||
None new. The existing `SECRET_KEY` continues to sign session
|
||
cookies; passcode hashing reuses the bcrypt dependency added in
|
||
v0.7.0. The lockout shape (5 attempts, 15 minutes) and the length
|
||
range (4–20) are hard-coded in `backend/app/passcode.py`. See
|
||
§19.2 for the env-tunable candidate.
|
||
|
||
## 0.9.0 — 2026-05-28
|
||
|
||
**Minor — no schema migration; reuses v0.8.0 columns and existing
|
||
SMTP.** This release ships the admin user-management surface at
|
||
`/admin/users` and the new-beta-request notifications that feed
|
||
it (roadmap item #7, SPEC §6.1 / §15 / §17). The two halves
|
||
compose: admins receive an inbox + email signal the moment a
|
||
pending user submits the v0.8.0 capture form; clicking through
|
||
lands on the page where they Grant or Revoke access.
|
||
|
||
The page consumes the v0.8.0 schema columns
|
||
(`permission_state`, `first_name`, `last_name`,
|
||
`beta_request_reason`, `permission_decided_by`,
|
||
`permission_decided_at`) without adding new ones — migration
|
||
slot 016 stays reserved for a future release. The single new
|
||
write endpoint, `POST /api/admin/users/<id>/permission`,
|
||
replaces v0.8.0's documented manual `UPDATE users SET
|
||
permission_state='granted'` gesture with an audited UI flip.
|
||
|
||
Admin notifications ride the existing §15 chokepoint — the
|
||
`new_beta_request` event_kind is added to the enum with
|
||
category `admin-actionable`, fan-out is to every owner / admin
|
||
minus the requester themselves, and the §15.4 email dispatch
|
||
only reaches recipients whose `email_admin_actionable` toggle
|
||
is on (the default for owners + admins). The event is the
|
||
framework's first non-RFC-scoped notification — `rfc_slug` is
|
||
NULL and the email deep-link points `/admin/users` instead of
|
||
`/rfc/<slug>`.
|
||
|
||
Decision on `/admin/allowlist`: the surface stays as a
|
||
sibling sub-tab, not folded into `/admin/users`. The two have
|
||
different keys (allowlist by email pre-sign-up, user list by
|
||
user_id post-sign-up) and a union row would be confusing
|
||
rather than clarifying. The allowlist's fast-path-bypass role
|
||
from v0.8.0 is unchanged; retiring the table outright is
|
||
deferred to a later session (see §19.2).
|
||
|
||
### Upgrade steps (from 0.10.0)
|
||
|
||
1. **MAY** rebuild the frontend. The build is the same shape
|
||
as v0.10.0; the lockfile pins to `0.9.0` so `npm install`
|
||
in `frontend/` updates it cleanly. No new env vars on the
|
||
frontend; the existing `VITE_APP_NAME` requirement carries
|
||
over.
|
||
2. **MAY** restart the backend. No schema migration runs in
|
||
this release — every v0.9.0 column is from
|
||
`014_beta_access.sql` (v0.8.0). Restart only if you want
|
||
the new endpoints registered in this version's binary.
|
||
3. **SHOULD** verify SMTP can reach the deployment's admin
|
||
inbox before the first pending user submits the capture
|
||
form. v0.9.0 reuses the v0.7.0 SMTP configuration
|
||
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`); a
|
||
misconfigured deployment will still write the inbox row,
|
||
but the admin won't hear about it through email. No new
|
||
env var is required — the framework reuses the existing
|
||
admin-user list (role IN ('owner', 'admin')) as the
|
||
notification recipients.
|
||
4. **SHOULD** announce the surface to existing admins. Wording
|
||
suggestion: "There's now a Users tab in /admin where you can
|
||
Grant or Revoke beta access — and you'll get an email +
|
||
inbox row when a fresh request lands. The manual SQL gesture
|
||
from v0.8.0 still works but is no longer the documented
|
||
path."
|
||
5. **MAY** drain the existing pending queue through the new UI.
|
||
If your deployment carried pending users through the v0.8.0
|
||
manual-UPDATE window, the Pending bucket on `/admin/users`
|
||
surfaces all of them with their captured profile. Granting
|
||
from the UI stamps `permission_decided_by` /
|
||
`permission_decided_at`, which any prior manual UPDATE
|
||
gestures may have left NULL (no harm done — the v0.8.0
|
||
contract didn't require those stamps).
|
||
|
||
### Added
|
||
|
||
- **`POST /api/admin/users/<id>/permission`** — body
|
||
`{state: 'pending'|'granted'|'revoked'}`. Flips the column,
|
||
stamps `permission_decided_by` + `permission_decided_at`,
|
||
and writes a `permission_events` row with event_kind in
|
||
`{permission_granted, permission_revoked,
|
||
permission_repended}`. Refuses 422 on self-flip (symmetric
|
||
to `set_mute` / `set_role` self-action refusals) and 422
|
||
on invalid state. Returns `{ok, permission_state, changed}`
|
||
where `changed=false` indicates a no-op (the requested
|
||
state already matched).
|
||
- **Widened `GET /api/admin/users` response** carrying
|
||
`permission_state`, `first_name`, `last_name`,
|
||
`beta_request_reason`, `created_at`,
|
||
`permission_decided_at`, plus joined
|
||
`permission_decided_by_login` /
|
||
`permission_decided_by_display`. Sort order surfaces
|
||
`pending` rows first (the admin queue), then `granted`,
|
||
then `revoked`; within a bucket, owners precede admins
|
||
precede contributors, with recency as the tiebreaker.
|
||
- **`new_beta_request` event_kind** in the §15.1 enum. Fired
|
||
by `notify.fan_out_new_beta_request` from the first
|
||
successful `POST /api/auth/me/beta-request` (re-submits
|
||
from the same pending user don't re-fire — the row's
|
||
first-time-complete check guards against carpet-bombing).
|
||
Recipients: every owner + admin minus the requester
|
||
themselves. Category: `admin-actionable`. Deep-link:
|
||
`/admin/users`. Actor: the requester per §15.9.
|
||
- **`/admin/users` page enhancements** in `Admin.jsx`. The
|
||
Users tab gains a state filter chip row (All / Pending /
|
||
Granted / Revoked with counts), a Grant / Revoke control
|
||
column, a Permission state badge, and an expandable
|
||
"why they want access" row beneath each pending user.
|
||
Sign-up timestamp surfaces in a new column.
|
||
- **`frontend/src/api.js#setUserPermission`** — client for
|
||
the new endpoint, neighboring `setUserMute` and
|
||
`setUserRole`.
|
||
- **`/beta-pending` copy update** in `BetaPending.jsx`. The
|
||
"your request is in review" page now honestly references
|
||
the admin-email signal v0.9.0 ships and admits the
|
||
framework does not commit to an SLA — turnaround depends
|
||
on operator availability, and the deployment operator is
|
||
the right person to ask if a wait runs long. No
|
||
deployment-specific text is baked in; per-deployment copy
|
||
lives in §13 of the deployment's repo, not in the
|
||
framework.
|
||
- **`backend/tests/test_admin_users_vertical.py`** — 10 new
|
||
tests covering: a beta-request submission fans
|
||
notifications to every admin + owner (and not to the
|
||
requester or to contributors); the requester's profile
|
||
fields land in the notification payload; re-submitting
|
||
the capture form does not re-fan; the `admin-actionable`
|
||
category mapping is wired; the `/api/admin/users`
|
||
listing carries the v0.8.0 columns with `pending` rows
|
||
sorted first; the permission-flip endpoint promotes
|
||
pending → granted with the right audit shape; the
|
||
endpoint promotes granted → revoked; the endpoint
|
||
refuses self-flip with 422; the endpoint refuses
|
||
non-admin callers with 403 and anonymous callers with
|
||
401; the endpoint refuses invalid states with 422; a
|
||
state-already-matches flip returns `changed=false`
|
||
without writing an audit row.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api.py`** — the `POST /api/auth/me/beta-request`
|
||
handler now calls `notify.fan_out_new_beta_request` after the
|
||
capture UPDATE lands, gated on the row not previously having
|
||
all three profile fields populated (so re-submits don't
|
||
re-fan).
|
||
- **`backend/app/api_admin.py`** — the `list_users` query joins
|
||
against `users d ON d.id = u.permission_decided_by` for the
|
||
deciding-admin handle. The new `set_permission` endpoint
|
||
lives alongside `set_role` / `set_mute`.
|
||
- **`backend/app/notify.py`** — adds
|
||
`CATEGORY_ADMIN_ACTIONABLE`, the `fan_out_new_beta_request`
|
||
helper, and the `new_beta_request` arm in `render_summary`.
|
||
- **`backend/app/email.py`** — the `_EVENT_TO_CATEGORY` map
|
||
carries `new_beta_request → admin-actionable`, and
|
||
`_deep_link` routes framework-scoped admin signals to
|
||
`/admin/users` instead of `/rfc/<slug>`.
|
||
- **`frontend/src/components/Admin.jsx`** — the `UsersTab`
|
||
component is rewritten with state filter chips, a per-row
|
||
`UserRow` + `PermissionCell` decomposition, and a
|
||
`useMemo`-cached counts table. The pending-row reason
|
||
blockquote renders as a secondary `<tr>` beneath the user
|
||
row when present.
|
||
- **`frontend/src/components/BetaPending.jsx`** — pending-state
|
||
copy revised to reference the admin email signal honestly.
|
||
- **`frontend/src/App.css`** — new admin-chip / permission-badge
|
||
/ user-row-reason rules.
|
||
- **`SPEC.md`** §6 opening, §15.1 event-kinds enum, §17 admin
|
||
endpoints, §19.2 candidates list — per §19.3 rule-2.
|
||
- **`VERSION`** → `0.9.0`. `frontend/package.json#version` and
|
||
the lockfile mirror.
|
||
|
||
### Environment variables
|
||
|
||
None new. The release reuses the v0.7.0 SMTP configuration
|
||
(`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`,
|
||
`SMTP_STARTTLS`, `EMAIL_FROM`, `EMAIL_FROM_NAME`,
|
||
`EMAIL_ENABLED`, `EMAIL_BUNDLE_THRESHOLD`) and the existing
|
||
admin-user list (role IN ('owner', 'admin')) as the
|
||
notification recipients. A deployment whose SMTP is misconfigured
|
||
will still see the inbox rows; only the email channel is muted.
|
||
|
||
### Deferred to later releases
|
||
|
||
- **Grant / revoke notification to the user** — symmetric
|
||
signal: when an admin grants or revokes access, fire a
|
||
`personal-direct` notification (event_kind
|
||
`permission_change_affecting_me`, already in the §15.1
|
||
enum) so the affected user sees the state change in
|
||
their inbox and email. v0.9.0 audits the gesture in
|
||
`permission_events` but does not yet escape the
|
||
app-internal log to the user. See §19.2.
|
||
- **Decline-with-reason on revoke** — the current Revoke
|
||
gesture takes only a confirmation; a follow-up release
|
||
may capture a free-text reason in
|
||
`permission_events.details`. See §19.2.
|
||
- **Allowlist deprecation** — the `/admin/allowlist` sub-tab
|
||
stays in place in v0.9.0 (the two surfaces have different
|
||
keys and a union row would be confusing). Retiring the
|
||
table outright is deferred to a session that can re-read
|
||
the post-v0.9.0 operator experience and decide whether
|
||
the fast-path-bypass role is still pulling weight. See
|
||
§19.2.
|
||
|
||
## 0.8.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; admission semantics shift.**
|
||
This release replaces the v0.3.0 / v0.7.0 `allowed_emails` admission
|
||
gate with an admin-grant flow (roadmap item #6, SPEC §6.1 / §6.2 /
|
||
§14.1 / §17). Anyone with a valid email can sign in via the v0.7.0
|
||
OTC flow; the OTC request endpoint no longer consults the
|
||
allowlist. A fresh user lands in `permission_state='pending'` until
|
||
an admin grants access. The first-OTC sign-in captures first name,
|
||
last name, and a free-text "why I should be included in the beta"
|
||
via a new `POST /api/auth/me/beta-request` endpoint; the captured
|
||
fields populate the same `users` row alongside the OAuth-era
|
||
columns.
|
||
|
||
A pending user has the same read access an anonymous viewer has —
|
||
the catalog, RFC bodies, the philosophy page, and every public
|
||
conversation are reachable. Every write-shaped endpoint
|
||
(`auth.require_contributor` floor) refuses pending users with 403.
|
||
The frontend renders a thin "Your beta access request is in
|
||
review" banner on every page and re-purposes the v0.3.0
|
||
`/beta-pending` page as the post-capture landing surface.
|
||
|
||
Grandfathered behavior: every `users` row at migration time
|
||
carries `permission_state='granted'` via the column default, so
|
||
existing contributors are unaffected. The OAuth fallback at
|
||
`/auth/callback` still consults the v0.3.0 allowlist (legacy
|
||
path); the OTC flow does not.
|
||
|
||
The `allowed_emails` table stays in the schema as a fast-path
|
||
bypass — the v0.3.0 admin UI continues to manage it, but the OTC
|
||
request handler no longer reads it. v0.9.0 (roadmap item #7) is
|
||
expected to ship the admin user-management page that replaces the
|
||
allowlist surface entirely; until then, admin grants are done by
|
||
direct DB `UPDATE`.
|
||
|
||
### Upgrade steps (from 0.13.0)
|
||
|
||
1. **MUST** restart the backend so migration `014_beta_access.sql`
|
||
runs. The migration adds `permission_state` (default `'granted'`,
|
||
so existing rows pass through unaffected), `first_name`,
|
||
`last_name`, `beta_request_reason`, `permission_decided_by`,
|
||
and `permission_decided_at` to the `users` table, plus an index
|
||
on `permission_state` for the pending queue. The migration is
|
||
ALTER-TABLE-based (no table rebuild) — every foreign key and
|
||
existing row passes through untouched.
|
||
2. **MUST** rebuild the frontend. The `Login.jsx` surface now
|
||
runs a conditional third step (the capture form) on fresh OTC
|
||
sign-ins; `BetaPending.jsx` carries the new "your request is
|
||
in review" copy; `App.jsx` renders a thin pending-access
|
||
banner. The build embeds the new `/api/auth/me/beta-request`
|
||
client call.
|
||
3. **SHOULD** announce the new admission flow to existing users.
|
||
Wording suggestion: "We've replaced our email-allowlist gate
|
||
with an admin-review flow. Existing users are unaffected;
|
||
new visitors sign in with their email, tell us a bit about
|
||
themselves, and an admin reviews their request before
|
||
discussion and contribution unlock." Existing sessions
|
||
remain valid.
|
||
4. **SHOULD** plan the admin grant mechanism. v0.8.0 does not
|
||
ship a UI for the grant — v0.9.0 (roadmap item #7) will. For
|
||
the v0.8.0 window, an admin grants access via direct DB
|
||
gesture:
|
||
```sql
|
||
UPDATE users
|
||
SET permission_state = 'granted',
|
||
permission_decided_by = <admin_user_id>,
|
||
permission_decided_at = datetime('now')
|
||
WHERE email = '<approved>';
|
||
```
|
||
The pending queue lives in `SELECT * FROM users WHERE
|
||
permission_state = 'pending' ORDER BY created_at`.
|
||
5. **MUST** decide whether to drain the `allowed_emails` table.
|
||
The OTC request handler no longer consults it; populated
|
||
rows are inert at the request surface. Three operator
|
||
choices, all valid:
|
||
* **Leave as-is** (the framework's default behavior — the
|
||
v0.3.0 admin UI continues to work, the rows stay as a
|
||
fast-path bypass record). Recommended if you anticipate
|
||
v0.9.0's user-management page folding the allowlist UI
|
||
into its surface.
|
||
* **Drain via the existing admin UI** (`/admin/allowlist`)
|
||
— one row at a time, no data loss elsewhere.
|
||
* **Bulk-drain via DB** — `DELETE FROM allowed_emails;`
|
||
drops every row; the table stays.
|
||
6. **MAY** announce write access individually to grandfathered
|
||
users you want to keep at `'granted'`. The default-`'granted'`
|
||
migration means no action is required for them; this step
|
||
exists only if you want to send a "you're still in" message.
|
||
|
||
### Added
|
||
|
||
- **`backend/migrations/014_beta_access.sql`** — adds
|
||
`permission_state` (CHECK in `('pending', 'granted', 'revoked')`,
|
||
default `'granted'`), `first_name`, `last_name`,
|
||
`beta_request_reason`, `permission_decided_by` (FK to users,
|
||
ON DELETE SET NULL), `permission_decided_at` to the `users`
|
||
table. Plus `idx_users_permission_state` for the pending
|
||
queue.
|
||
- **`POST /api/auth/me/beta-request`** — body
|
||
`{first_name, last_name, beta_request_reason}` (all required;
|
||
bounds 120 / 120 / 4000). Writes the fields to the signed-in
|
||
user's row and leaves `permission_state='pending'`. Refuses
|
||
HTTP 409 for already-granted / revoked users; refuses HTTP
|
||
401 for anonymous callers.
|
||
- **`needs_profile` flag** on the `/auth/otc/verify` response.
|
||
`true` iff the user is `permission_state='pending'` AND
|
||
carries no profile fields yet (a fresh OTC sign-in). The
|
||
Login.jsx surface uses the flag to gate the capture step.
|
||
- **`permission_state` field** on the `/api/auth/me` response,
|
||
plus `first_name`, `last_name`, `beta_request_reason`, and
|
||
the same `needs_profile` flag.
|
||
- **First-OTC profile capture step** in `Login.jsx`. Third
|
||
step in the sign-in surface, gated by the verify response's
|
||
`needs_profile` flag.
|
||
- **`/beta-pending` repurpose** in `BetaPending.jsx`. The
|
||
page now reads as "your request is in review" when the
|
||
viewer is pending; the v0.3.0 "private beta" framing
|
||
remains as the anonymous-viewer fallback.
|
||
- **Thin pending-access banner** at the top of every page
|
||
for `permission_state='pending'` viewers (other than
|
||
`/beta-pending` itself).
|
||
- **SPEC `§6` opening / `§6.1` / `§6.2` / `§14.1` / `§17` /
|
||
`§19.2`** corrections per §19.3 rule-2 — the admission
|
||
shift, the orthogonality of permission_state vs role / muted
|
||
/ notification-mutes, the new endpoints, and the
|
||
newly-surfaced §19.2 candidates (admin user-management page,
|
||
allowlist deprecation, admin notification on new request).
|
||
- **`backend/tests/test_beta_access_vertical.py`** — 9 new
|
||
tests covering: a fresh OTC user lands pending with empty
|
||
profile; the capture endpoint populates the fields and
|
||
keeps state pending; the capture endpoint refuses
|
||
anonymous / granted / revoked callers; a pending user is
|
||
refused write endpoints; an admin grant promotes pending →
|
||
granted; a grandfathered user is unaffected by the
|
||
migration; the OTC request endpoint accepts any email
|
||
regardless of allowlist state; the `allowed_emails` table
|
||
is still present in the schema.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/auth.py#require_contributor`** widens its
|
||
gate to refuse `permission_state != 'granted'` with HTTP
|
||
403. The §6.1 contributor capabilities (propose, branch,
|
||
PR, chat, claim) all funnel through this dependency, so
|
||
the widening covers them transitively. `SessionUser` now
|
||
carries `permission_state` (default `'granted'` for the
|
||
dataclass-default fallback path).
|
||
- **`backend/app/otc.py#request_code`** drops the allowlist
|
||
check from the OTC request flow. The `RequestOutcome`
|
||
shape loses the `'allowlist'` reason (replaced by
|
||
`'sent'` / `'cooldown'` / `'invalid'`).
|
||
- **`backend/app/otc.py#provision_or_link_user`** sets
|
||
`permission_state='pending'` explicitly on a fresh row.
|
||
Grandfathered (link-by-email) users pass through with
|
||
their existing column value.
|
||
- **`backend/app/auth.py#provision_user`** (OAuth fallback)
|
||
now sets `permission_state='granted'` explicitly on a
|
||
fresh row. The OAuth callback still consults the
|
||
`is_allowed_sign_in` allowlist check (the legacy fallback
|
||
path retains its v0.3.0 admission shape during the OAuth
|
||
migration window).
|
||
- **`backend/tests/test_otc_vertical.py`** — the
|
||
`test_otc_request_silently_drops_when_email_not_on_allowlist`
|
||
test (asserted the v0.7.0 allowlist gate) is replaced by
|
||
`test_otc_request_admits_emails_regardless_of_allowlist_population`
|
||
which asserts the v0.8.0 open-request contract. The
|
||
on-list test stays as a regression net for the
|
||
rate-limit / outbound-buffer plumbing.
|
||
- **`SPEC.md`** §6 opening, §6.1, §6.2, §14.1, §17, §19.2
|
||
per §19.3 rule-2.
|
||
- **`VERSION`** → `0.8.0`. `frontend/package.json#version` and
|
||
the lockfile mirror.
|
||
|
||
### Deferred to later releases
|
||
|
||
- **Admin user-management page** at `/admin/users` (item #7,
|
||
v0.9.0) — replaces the manual DB `UPDATE` gesture.
|
||
- **Allowlist UI deprecation** (also v0.9.0) — once the
|
||
admin user-management page lands, the `/admin/allowlist`
|
||
surface and the `allowed_emails` table both retire.
|
||
- **Admin email notification on new beta request** (item #7
|
||
again, v0.9.0).
|
||
- **Revoke gesture in the UI** — the `permission_state='revoked'`
|
||
state is wired in the schema and the auth gate; v0.9.0 ships
|
||
the admin UI that flips the column.
|
||
|
||
## 0.7.0 — 2026-05-28
|
||
|
||
**Minor — schema migration required; new auth path is additive.**
|
||
This release lands email + one-time-code sign-in (roadmap item #5,
|
||
SPEC §6.2) as the primary human-auth gesture. Users sign in by
|
||
typing their email, receiving a six-digit code via email, and
|
||
entering it. The Gitea OAuth callback (`/auth/callback`) remains
|
||
functional during migration — the new UI no longer points at it
|
||
primarily, but a "Sign in with Gitea (fallback)" link survives on
|
||
the new login surface so users with active OAuth sessions or older
|
||
invite paths still have a way in. A future release retires the
|
||
OAuth path entirely once every active user has signed in at least
|
||
once via OTC.
|
||
|
||
The migration path for existing users: first OTC sign-in matches by
|
||
`users.email` (case-insensitive) to the OAuth-era row and reuses
|
||
that row's `id` and `gitea_id`. New users provisioned via OTC carry
|
||
`gitea_id = NULL` and `gitea_login = NULL`. The `gitea_id` linker
|
||
remains the canonical handle for grandfathered users; `email`
|
||
becomes the identity key for everything provisioned after v0.7.0.
|
||
|
||
The Gitea **bot** user + token are still required (server-side git
|
||
operations — repo reads, PR creation — still flow through it). Only
|
||
the operator-facing sign-in surface moves.
|
||
|
||
### Upgrade steps (from 0.6.0)
|
||
|
||
1. **MUST** restart the backend so migration `012_otc.sql` runs.
|
||
The migration rebuilds the `users` table (SQLite cannot ALTER
|
||
COLUMN); existing rows pass through unchanged, but the new
|
||
schema relaxes `gitea_id` / `gitea_login` to nullable (with
|
||
partial unique indexes that ignore NULL) and adds a partial
|
||
unique index on `email`. A new `otc_codes` table is created.
|
||
2. **MUST** confirm the SMTP overlay is set (`SMTP_HOST`,
|
||
`SMTP_PORT`, `SMTP_USER`, `SMTP_PASSWORD`, `SMTP_STARTTLS`,
|
||
`EMAIL_FROM`, `EMAIL_FROM_NAME`). The OHM overlay already
|
||
carries these as of v0.5.0; deployments without them fall back
|
||
to logging the code to stdout (dev-only path — production
|
||
visitors will not receive their codes).
|
||
3. **SHOULD** announce the new email-based sign-in to existing
|
||
users. Wording suggestion: "You can now sign in by entering
|
||
your email and a one-time code we'll send you. Your old
|
||
account is linked automatically the first time you sign in."
|
||
4. **MAY** keep the existing OAuth callback as a fallback path.
|
||
The new login UI surfaces a small "Sign in with Gitea
|
||
(fallback)" link beneath the primary email/code form; a
|
||
deployment that prefers to hide it can override the Login
|
||
component in a future framework release that exposes the link
|
||
behind a feature flag. For v0.7.0, the link is hard-coded.
|
||
|
||
### New environment variables (all optional with defaults)
|
||
|
||
- `OTC_TTL_MINUTES` (default `10`) — how long a one-time code is
|
||
valid after issuance. Re-requesting invalidates the prior code
|
||
immediately regardless of TTL.
|
||
- `OTC_REQUEST_COOLDOWN_SECONDS` (default `60`) — per-email cooldown
|
||
between successive `/auth/otc/request` calls. The endpoint returns
|
||
HTTP 429 when the cooldown blocks a request (the loud-failure
|
||
shape; the abuse path is visible rather than swallowed).
|
||
|
||
No new secrets are required. The existing `SECRET_KEY` continues to
|
||
sign session cookies; OTC codes are bcrypt-hashed at rest using a
|
||
per-row salt the library generates.
|
||
|
||
### Added
|
||
|
||
- **`POST /auth/otc/request`** — body `{email}`. Generates a six-digit
|
||
code, stores its bcrypt hash with an expiry, and dispatches a plain
|
||
text email via the existing SMTP layer. Returns HTTP 200 (`{ok:true}`)
|
||
uniformly so allowlist state is not leaked. Returns HTTP 429 when
|
||
the per-email cooldown blocks the request.
|
||
- **`POST /auth/otc/verify`** — body `{email, code}`. Validates the
|
||
bcrypt hash against the most-recent unconsumed non-expired row, marks
|
||
the row consumed, provisions or links the `users` row by email, and
|
||
stores the session cookie. Returns HTTP 200 on success, HTTP 400 on
|
||
any failure (expired, consumed, wrong, unknown).
|
||
- **`backend/migrations/012_otc.sql`** — creates `otc_codes` and
|
||
rebuilds `users` with nullable `gitea_id` / `gitea_login` plus a
|
||
partial unique index on `email`.
|
||
- **`backend/app/otc.py`** — the OTC request/verify state machine and
|
||
the `provision_or_link_user` linker.
|
||
- **`backend/app/email_otc.py`** — outbound OTC mail composition. Reuses
|
||
the SMTP plumbing from `email.py` (`EmailConfig.from_env()`) and the
|
||
test buffer (`_SENT`) but writes its own envelope (no unsubscribe
|
||
footer, no quiet-hours hold — OTC mail carries a credential and
|
||
ignores notification preferences).
|
||
- **`frontend/src/components/Login.jsx`** — two-step sign-in surface
|
||
at `/login`. Step 1: enter email → request code. Step 2: enter
|
||
six-digit code → verify. Cmd/Ctrl+Enter on the code field submits.
|
||
The previous header "Sign in" link and the `Welcome` component's
|
||
inline link now route to `/login` instead of jumping straight to
|
||
the Gitea OAuth dance.
|
||
- **SPEC `§6.1` / `§6.2` / `§14.1` / `§17` / `§19.2`** corrections per
|
||
§19.3 rule-2 — see below.
|
||
- **`backend/tests/test_otc_vertical.py`** — 11 new tests covering
|
||
the happy path, expired/consumed/wrong codes, the per-email rate
|
||
limit (and its per-email isolation), the allowlist gate, the
|
||
migration link to OAuth-era users, fresh provisioning, and the
|
||
prior-code-invalidation behavior on re-request.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/auth.py#current_user`** — coerces NULL `gitea_id` and
|
||
NULL `gitea_login` to `0` / `""` so the `SessionUser` shape stays
|
||
stable for OTC-only users. The DB remains the source of truth for
|
||
"is this user OAuth-linked" (`gitea_id IS NOT NULL`).
|
||
- **`backend/requirements.txt`** — adds `bcrypt>=4.2` for OTC code
|
||
hashing. Pure-Python wheels are available on every platform the
|
||
deployment matrix targets; `pip install -r backend/requirements.txt`
|
||
picks it up.
|
||
- **`frontend/src/App.jsx`** — adds the `/login` route and replaces
|
||
the header "Sign in" `<a href="/auth/login">` with `<Link to="/login">`.
|
||
The `Welcome` component's inline sign-in link follows suit.
|
||
- **`frontend/src/components/Landing.jsx`** — the `/welcome` page's
|
||
primary action moves from "Sign in with Gitea" to "Sign in" pointing
|
||
at `/login`.
|
||
|
||
### Deferred to later releases
|
||
|
||
Per the v0.7.0 scope discipline (the foundation for items #6, #8, #9,
|
||
#10), several adjacent capabilities are intentionally not in this
|
||
release and surface as §19.2 candidates:
|
||
|
||
- **First-OTC profile capture** (first name, last name, "why") — item
|
||
#6, expected v0.8.0.
|
||
- **Open beta-access request flow** replacing the allowlist gate —
|
||
also item #6, v0.8.0.
|
||
- **Passcodes** (a long-term reauth token alternative) — item #8,
|
||
expected v0.10.0.
|
||
- **Device-trust 30-day skip** — item #9, expected v0.11.0.
|
||
- **Cloudflare Turnstile** on `/auth/otc/request` — item #10,
|
||
expected v0.12.0.
|
||
- **Removing the Gitea OAuth `/auth/callback` route entirely** — a
|
||
later release after every active user has signed in via OTC.
|
||
|
||
## 0.6.0 — 2026-05-28
|
||
|
||
**Minor — no operator action required.** A sweep-the-edges hardening
|
||
release (roadmap item #4, "anon discuss + contribute off-limits") that
|
||
audits every write-shaped backend endpoint and asserts each one
|
||
enforces an explicit `auth.require_contributor` (or stricter) gate
|
||
before doing any state-changing work. v0.3.0 hid write affordances
|
||
behind a sign-in CTA on the frontend; v0.5.0 added the PR-less
|
||
discussion surface with its own write gate; v0.6.0 sweeps the rest
|
||
and adds a regression test net so future endpoints can't quietly
|
||
ship without a gate. No schema migration, no env-var changes, no
|
||
new dependencies. The only behavioural change is one tightening: the
|
||
`GET /api/rfcs/<slug>/graduate/progress` SSE now requires
|
||
`auth.require_user` since it surfaces operator-visible step detail
|
||
(repo name, PR number, rollback steps) not part of the v0.3.0
|
||
anonymous-read contract for catalog/RFC bodies.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/api_graduation.py`** — `GET /graduate/progress`
|
||
now calls `auth.require_user(request)` as its first line.
|
||
Anonymous callers receive 401 instead of being able to subscribe
|
||
to a graduation's SSE. The floor is `require_user` (not
|
||
`require_contributor`) so a write-muted operator can still observe
|
||
the progress of a graduation they kicked off before being muted.
|
||
- **SPEC `§6.1`** (`SPEC.md`) — the Anonymous role's bullet now
|
||
documents the v0.6.0 audit: every write-shaped endpoint in §17
|
||
enforces an explicit gate; anonymous writes refuse 401. The list
|
||
of audited write families is recorded in-line.
|
||
- **SPEC `§10.10`** — the discussion-vs-contribution section now
|
||
records that the v0.5.0 write gates have a regression test net
|
||
(`test_anon_offlimits_vertical.py`) added in v0.6.0.
|
||
- **SPEC `§17`** — the `GET /api/rfcs/<slug>/graduate/progress`
|
||
bullet now documents the `require_user` gate added in v0.6.0,
|
||
with the rationale.
|
||
|
||
### Added (tests)
|
||
|
||
- **`backend/tests/test_anon_offlimits_vertical.py`** — twelve new
|
||
tests asserting that every write-shaped endpoint surveyed in the
|
||
v0.6.0 audit refuses anonymous callers with 401, and that the five
|
||
anonymous-read surfaces (health, philosophy, auth/me, catalog,
|
||
RFC view, discussion threads, proposals) stay reachable. Sixty-six
|
||
assertions in total, covering: propose; proposal merge / decline /
|
||
withdraw; branch promote-to-branch / start-edit-branch / metadata /
|
||
manual-flush / visibility / grants (POST + DELETE) / threads (POST)
|
||
/ messages (POST) / resolve / chat-seen / changes (accept / decline
|
||
/ reask) / chat-stream; super-draft start-edit-branch + metadata;
|
||
PR pr-draft / open / seen / review / merge / withdraw /
|
||
description / resolution-branch; discussion thread create + message
|
||
post + resolve; admin role / mute / allowlist (POST + DELETE) plus
|
||
the admin reads; notification preferences / quiet-hours / watch /
|
||
mark-read / user-mute (POST + DELETE); funder credentials (POST +
|
||
DELETE) + consent (POST + DELETE); graduation kickoff + claim +
|
||
progress SSE; PR review page anonymous-readable.
|
||
|
||
### Anonymous-writeable allowlist
|
||
|
||
Two endpoints are intentionally anonymous-by-design. They are not
|
||
audit findings; they are documented here so the contract is
|
||
explicit:
|
||
|
||
- `GET /auth/login` and `GET /auth/callback` — the OAuth
|
||
round-trip. Anonymous-by-design because they ARE the sign-in
|
||
entrypoint.
|
||
- `POST /api/webhooks/gitea` and `POST /api/webhooks/email-bounce`
|
||
— anonymous in the session sense but authenticated by HMAC shared
|
||
secret (`GITEA_WEBHOOK_SECRET` and `WEBHOOK_EMAIL_BOUNCE_SECRET`
|
||
respectively). The webhook receiver is the wrong place for an
|
||
authenticated session; the shared-secret shape is correct.
|
||
|
||
### §19.2 candidates surfaced
|
||
|
||
- None unique to this release. The two pre-existing candidates the
|
||
audit touched — anonymous-read polish for the discussion surface
|
||
(carried from v0.5.0) and the operator-visibility floor on
|
||
graduation progress — were settled here as `require_user` on
|
||
`/graduate/progress` rather than deferred.
|
||
|
||
### Upgrade steps (from 0.5.0)
|
||
|
||
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||
npm run build`) so the frontend bundle's reported version matches
|
||
the backend's. No new env vars; existing `frontend/.env` is
|
||
sufficient.
|
||
2. The deployment **MUST** restart the backend so the new
|
||
`require_user` gate on `/graduate/progress` is enforced. No
|
||
schema migration runs.
|
||
3. The deployment **MAY** announce the audit completion to
|
||
operators: every write-shaped backend endpoint now enforces an
|
||
explicit gate, and the `test_anon_offlimits_vertical.py` test
|
||
net asserts the contract on every CI run. The audited surfaces
|
||
are listed in the `Added (tests)` section above and in `SPEC.md`
|
||
§6.1.
|
||
4. The deployment **MUST NOT** assume any new envelope behaviour:
|
||
v0.6.0 is purely a hardening release. No schema, no env, no
|
||
dependency changes; the operator's role is reduced to rebuild +
|
||
restart.
|
||
|
||
|
||
## 0.5.0 — 2026-05-27
|
||
|
||
**Minor — no operator action required.** This release wires the
|
||
PR-less per-RFC discussion surface (roadmap item #3, SPEC §10.10).
|
||
An RFC's main view now carries a discussion panel distinct from PR
|
||
comments and branch chat; contribution — proposing edits the document
|
||
will land — still requires opening a PR via the §10.1 affordance.
|
||
The substrate is the existing `threads` / `thread_messages` tables;
|
||
rows with `threads.branch_name IS NULL` scope to the RFC's main view.
|
||
No schema migration is required.
|
||
|
||
### Added
|
||
|
||
- **PR-less discussion endpoints** (`backend/app/api_discussion.py`)
|
||
mounted at `/api/rfcs/<slug>/discussion/...`:
|
||
- `GET /api/rfcs/<slug>/discussion/threads` — list threads where
|
||
`threads.branch_name IS NULL`. Anonymous-readable per the v0.3.0
|
||
contract; the default whole-doc chat thread is materialized
|
||
lazily on first read.
|
||
- `POST /api/rfcs/<slug>/discussion/threads` — open a new
|
||
discussion thread (`thread_kind='chat'`, `anchor_kind='whole-doc'`,
|
||
`branch_name=NULL`). Body: optional `label`, optional first
|
||
`message`. Requires contributor role.
|
||
- `GET /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
|
||
— read messages on a discussion thread. Anonymous-readable.
|
||
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/messages`
|
||
— post a message. Body: `text`, optional `quote`. Requires
|
||
contributor role.
|
||
- `POST /api/rfcs/<slug>/discussion/threads/<thread_id>/resolve`
|
||
— resolve a discussion thread. Permission per §10.10: thread
|
||
creator, RFC owner / arbiter, or app admin / owner.
|
||
- **`RFCDiscussionPanel.jsx`** — the right-column surface on the RFC
|
||
view when the viewer is on `main`. Composer requires sign-in;
|
||
Cmd/Ctrl+Enter sends. Multiple threads surface as pill-shaped
|
||
tabs above the message feed. A "New thread" affordance opens a
|
||
fresh thread on the same RFC.
|
||
- **SPEC `§10.10` PR-less discussion vs. contribution** — settles
|
||
the distinction between discussion (RFC-scoped, no PR, both
|
||
anonymous-readable and contributor-writeable) and contribution
|
||
(still requires a PR via §10.1). Also extends `§5`'s `threads`
|
||
table commentary so the null-branch interpretation is documented
|
||
as actively used rather than reserved scaffold.
|
||
- **SPEC `§17`** — lists the five new `discussion/...` endpoints
|
||
in the illustrative table.
|
||
|
||
### Changed
|
||
|
||
- **`backend/app/chat.py`** — `_fan_out_chat` now passes
|
||
`branch_name` straight through to the notify chokepoint instead of
|
||
coercing `None` to `"main"`. The notifications row carries the
|
||
null through, which preserves the §15.7 reconciler's keying on
|
||
`(rfc_slug, branch_name)` for the eventual discussion-side
|
||
chat-seen advance (deferred to a §19.2 candidate). Existing
|
||
branch-scoped chat continues to pass non-null branch names; the
|
||
change is invisible to that path.
|
||
- **`backend/app/notify.py`** — `fan_out_chat_message`'s
|
||
`branch_name` parameter is now typed `str | None` to match the
|
||
v0.5.0 PR-less shape. Routing rules are unchanged; the inbox prose
|
||
renders identically whether the chat lives on a branch or on the
|
||
RFC's discussion surface, which is the right honest signal.
|
||
- **`RFCView.jsx`** — the right-column panel is now conditional:
|
||
when `branchParam === 'main'`, render `RFCDiscussionPanel`
|
||
(the new PR-less surface); otherwise render the existing
|
||
`ChatPanel` (branch chat unchanged).
|
||
|
||
### §19.2 candidates surfaced
|
||
|
||
- PR-less discussion: range / paragraph anchors (the data model
|
||
permits them; the UI is the deferred part).
|
||
- PR-less discussion: distinct notification `event_kind`s
|
||
(`open_rfc_discussion_thread`, etc.) if usage shows contributors
|
||
want to filter discussion-vs-branch in the §15.2 inbox.
|
||
- PR-less discussion: chat-seen cursor closing the §15.7
|
||
reconciliation loop for the new surface.
|
||
- PR-less discussion: AI participant invocation (discussion-only,
|
||
no `<change>` block side-effects).
|
||
- PR-less discussion: anonymous-read polish to match the v0.6.0
|
||
write-gate hardening (item #4).
|
||
|
||
### Upgrade steps (from 0.4.0)
|
||
|
||
1. The deployment **MUST** rebuild the frontend (`npm install &&
|
||
npm run build`) so `RFCDiscussionPanel.jsx` ships in the bundle.
|
||
No new env vars; existing `frontend/.env` is sufficient.
|
||
2. The deployment **MUST** restart the backend so the new
|
||
`api_discussion` router mounts. No schema migration runs — the
|
||
`threads` table already supports `branch_name IS NULL` per §5,
|
||
and the v0.5.0 build is the first to write rows in that shape.
|
||
3. The deployment **MAY** announce the new surface to its
|
||
contributors: the RFC view's main page now carries a discussion
|
||
panel below the document. Existing branch chat and PR review
|
||
surfaces are unchanged.
|
||
4. The deployment **SHOULD NOT** expect a hardening of the
|
||
anonymous-write gate in v0.5.0 — that lands in v0.6.0 (item #4).
|
||
v0.5.0's write paths already refuse anonymous posts, so no
|
||
pre-emptive operator action is needed.
|
||
|
||
## 0.4.0 — 2026-05-27
|
||
|
||
**Minor — no operator action required beyond rebuild + restart.** The
|
||
proposer of a new RFC is now the implicit first owner of its super-
|
||
draft entry, set automatically at propose time from the session user.
|
||
The §13.1 claim flow remains available for *additional* owners. No
|
||
schema changes, no env-var changes, no API-shape changes; only newly
|
||
proposed RFCs receive the auto-owner — existing super-drafts whose
|
||
`owners:` is empty are unaffected and can still be claimed via §13.1
|
||
as before. This is a §19.3 rule-2 spec correction: `SPEC.md` §9.1,
|
||
§9.2, and §13.1 are updated to reflect the new shape.
|
||
|
||
### Changed
|
||
|
||
- **`POST /api/rfcs/propose`** (`backend/app/api.py`) now sets
|
||
`Entry.owners = [user.gitea_login]` when constructing the new
|
||
super-draft entry, instead of `owners=[]`. The session's
|
||
`gitea_login` is the canonical source; the endpoint never accepted
|
||
an owner field from the request payload and still doesn't.
|
||
- **`SPEC.md` §9.1** narrowed: the "no proposed-owner or working-
|
||
group fields" sentence becomes a proposer-owner-auto / working-
|
||
group-deferred split, with a §19.3 rule-2 note.
|
||
- **`SPEC.md` §9.2** frontmatter shape: `owners: []` → `owners:
|
||
[<proposer.gitea_login>]`, with a §19.3 rule-2 note.
|
||
- **`SPEC.md` §13.1** reframed: claim flow is now a graduation-time
|
||
broadening for additional owners, not a precondition for the
|
||
proposer's own RFC. The §13.1 / §13.2 / §13.3 graduation pipeline
|
||
itself is unchanged — the "at least one owner" precondition for
|
||
graduation still holds and is now satisfied by default.
|
||
|
||
### Upgrade steps (from 0.3.0)
|
||
|
||
1. The deployment **MUST** rebuild and restart per the routine
|
||
deploy steps. No `.env` changes, no schema/migration changes, no
|
||
API-shape changes.
|
||
2. Operators **SHOULD** note that newly proposed RFCs after the
|
||
upgrade carry the proposer in `owners:` automatically. Existing
|
||
super-drafts with empty `owners:` are not migrated; they remain
|
||
claimable via the §13.1 flow exactly as before. No deployment-
|
||
side data action is required.
|
||
3. Deployments **MAY** communicate the UX shift to active proposers
|
||
(the "Claim ownership" affordance no longer applies to your own
|
||
newly proposed RFC), but the affordance simply hides on RFCs the
|
||
viewer already owns, so no operator-side action is required.
|
||
|
||
## 0.3.0 — 2026-05-27
|
||
|
||
**Minor — operator action required if a deployment wants to enable the
|
||
private-beta gate; no action required to stay open.** This release adds
|
||
an email allowlist that, when populated, restricts OAuth sign-in to the
|
||
listed emails while keeping all read paths public. Anonymous visitors
|
||
now see the full app (catalog, RFC bodies, public branch conversations)
|
||
in read-only mode instead of the §14.1 landing-page wall.
|
||
|
||
### Added
|
||
|
||
- **`allowed_emails` table** (`backend/migrations/011_allowlist.sql`).
|
||
Empty list = gate off (any successful OAuth provisions a user, as
|
||
before). Any rows present = gate on (only listed emails, plus
|
||
users already grandfathered by `gitea_id`, may sign in).
|
||
- **Admin → Allowlist tab** at `/admin/allowlist`. Add/remove emails,
|
||
see who added each row and when. Status banner shows whether the
|
||
gate is currently active.
|
||
- **`/beta-pending` page** shown after a rejected OAuth callback. Free-
|
||
text invite-contact line is configurable via the new
|
||
`VITE_BETA_CONTACT` env var (optional; falls back to a generic line).
|
||
- **Beta chips** next to the Discuss/Contribute mode toggle, the Sign
|
||
in link, and the header Sign-in button so anonymous viewers see
|
||
immediately what is gated.
|
||
- **Anonymous read mode** in the React app: the §14.1 Landing page is
|
||
preserved at `/welcome` for deployments that want to link to it, but
|
||
the default route now renders the full app shell with write
|
||
affordances hidden behind a sign-in CTA.
|
||
|
||
### Changed
|
||
|
||
- **`/auth/callback`** now consults `auth.is_allowed_sign_in()` after
|
||
fetching the Gitea profile. Rejected sign-ins clear the OAuth state
|
||
and redirect to `/beta-pending`; the session is not populated.
|
||
- **`Catalog`** receives a `viewer` prop. Anonymous viewers see "Sign
|
||
in to propose (Beta)" instead of "+ Propose New RFC".
|
||
- **`PhilosophyWithSidebar`** now reads `authenticated` from the
|
||
current viewer instead of hardcoded `true`.
|
||
|
||
### Fixed
|
||
|
||
- **Single-finger scroll on the `/philosophy` page** (and any other
|
||
`.chrome-pane`-hosted view: `/admin/*`, `/settings/notifications`)
|
||
was broken on iOS Safari. The `.app` container used `height: 100vh`,
|
||
which on iOS measures the URL-bar-hidden ("largest") viewport — so
|
||
`.app` overflowed what's actually visible. Combined with the
|
||
`body { overflow: hidden }` in `index.css`, this meant single-finger
|
||
touches on the visible area were consumed by the (blocked) page-
|
||
level scroll attempt rather than reaching the nested `.chrome-pane`
|
||
scroll. Two-finger touches bypassed the page-level layer and
|
||
one-finger then worked once the URL bar had collapsed. Switched
|
||
`.app` to `height: 100dvh` (dynamic viewport — adjusts as the URL
|
||
bar shows/hides), with `100vh` retained as a fallback for browsers
|
||
predating iOS 15.4 / Chrome 108.
|
||
|
||
### Upgrade steps (from 0.2.3)
|
||
|
||
1. The deployment **MUST** rebuild the frontend with the new
|
||
`VITE_BETA_CONTACT` env var optionally set in `frontend/.env` (it
|
||
is OK to leave it blank — the `/beta-pending` page falls back to
|
||
a generic line).
|
||
2. The deployment **MUST** restart the backend so migration
|
||
`011_allowlist.sql` runs. No data loss; the new table starts
|
||
empty, which keeps the gate off and preserves existing behavior.
|
||
3. To **enable** the private-beta gate, the deployment operator
|
||
**SHOULD** sign in once (so their `users` row exists and they
|
||
grandfather in by `gitea_id`), then open `/admin/allowlist` and
|
||
add the first invited email. The first row added turns the gate
|
||
on for any user not yet in `users`.
|
||
4. To **stay open**, do nothing — leave `allowed_emails` empty and
|
||
the deployment behaves exactly as 0.2.3.
|
||
5. The deployment **MAY** customise its `/beta-pending` contact line
|
||
by setting `VITE_BETA_CONTACT` (an email, a URL, or a short
|
||
instruction) before the frontend build. Unset is fine.
|
||
|
||
## 0.2.3 — 2026-05-26
|
||
|
||
**Patch — no operator action required.** Rebuild and restart per the
|
||
routine deploy steps; no `.env` changes, no schema changes, no
|
||
behavior changes a deployment would notice in steady state beyond
|
||
the new endpoint below.
|
||
|
||
### Added
|
||
|
||
- **`GET /api/health`** — an unauthenticated probe returning JSON
|
||
`{version, status}` for ops tooling. `version` is the running
|
||
framework version (the contents of `VERSION` per §20.1, read at
|
||
process start and cached as a module-level constant in
|
||
`backend/app/health.py`); `status` is `"ok"` with HTTP 200 in
|
||
v1. The `"degraded"` / HTTP 503 path stays reserved in the
|
||
response shape so a later release can wire real degradation
|
||
conditions without breaking the contract. The version-match
|
||
check is the structural value — a deploy-control-panel polling
|
||
the endpoint after `systemctl restart` catches the failure mode
|
||
where a restart did not pick up the new code. See `SPEC.md` §17
|
||
and the §19.2 settlement.
|
||
|
||
### Upgrade steps (from 0.2.2)
|
||
|
||
1. The deployment **MAY** configure its monitoring (Pingdom,
|
||
Healthchecks.io, the flotilla deploy control panel, etc.) to
|
||
probe `/api/health` and compare the returned `version` against
|
||
the tag last deployed. The endpoint is unauthenticated by
|
||
design — no PII in the payload, no session required.
|
||
|
||
## 0.2.2 — 2026-05-26
|
||
|
||
**Patch — no operator action required.** Rebuild the frontend and
|
||
restart per the routine deploy steps; no `.env` changes, no schema
|
||
changes, no behavior changes a deployment would notice in steady
|
||
state beyond the fix below.
|
||
|
||
### Fixed
|
||
|
||
- **Mermaid blocks rendered as raw code on `/philosophy`.**
|
||
`frontend/src/components/Philosophy.jsx` parsed PHILOSOPHY.md with
|
||
the bare `marked` import and `dangerouslySetInnerHTML`, so
|
||
```` ```mermaid ```` fences fell through as `<pre>` blocks rather
|
||
than rendered diagrams. Swapped to `MarkdownPreview`, which already
|
||
carries the lazy mermaid loader + SVG render path used by the RFC
|
||
body view, so the philosophy surface now renders mermaid the same
|
||
way RFC bodies do. Pre-existing gap, surfaced when a deployment
|
||
authored a mermaid block in its `PHILOSOPHY_PATH` override.
|
||
|
||
## 0.2.1 — 2026-05-26
|
||
|
||
**Patch — no operator action required.** Rebuild the frontend and
|
||
restart per the routine deploy steps; no `.env` changes, no schema
|
||
changes, no behavior changes a deployment would notice in steady
|
||
state.
|
||
|
||
### Fixed
|
||
|
||
- **PR view blank page (React #310).** `frontend/src/components/PRView.jsx`
|
||
declared its `threadsByKind` `useMemo` *after* the early-return
|
||
guard for the loading state (`if (!pr) return …`). On first mount
|
||
`pr` was null so the early return fired and 14 hooks were called;
|
||
on the second render `pr` was populated and execution reached the
|
||
`useMemo`, calling 15 hooks — violating the Rules of Hooks and
|
||
unmounting the page subtree (blank screen). The `useMemo` is now
|
||
declared above the early returns with optional-chaining on `pr`,
|
||
keeping the hook count stable between the loading and loaded
|
||
renders. Pre-existing bug in the post-Contribute-rewrite PR view;
|
||
surfaced in production by 0.2.0's graduation merge race fix
|
||
enabling the workflow that opens this code path.
|
||
|
||
## 0.2.0 — 2026-05-26
|
||
|
||
**Breaking config change.** The frontend now requires
|
||
`VITE_APP_NAME` to be set at build time. `npm run build` fails with a
|
||
clear message if it is missing. There is no default; every deployment
|
||
names itself.
|
||
|
||
### Upgrade steps (from 0.1.0)
|
||
|
||
1. The deployment **MUST** create a `frontend/.env` file before
|
||
building. See `frontend/.env.example` for the contract.
|
||
2. The deployment **MUST** set `VITE_APP_NAME` in `frontend/.env` to
|
||
the user-visible name it wants to ship — the string used as the
|
||
browser tab title, the header brand, and the landing H1. The
|
||
build will fail loudly if this is unset or blank.
|
||
3. The deployment **MUST** rebuild the frontend (`npm install && npm
|
||
run build`) and redeploy the resulting bundle. The previous
|
||
bundle does not read `VITE_APP_NAME`.
|
||
4. The deployment **SHOULD** verify the name appears correctly in
|
||
the browser tab and the header after redeploy before bumping its
|
||
`.rfc-app-version` pin to `0.2.0`.
|
||
5. The deployment **MAY**, in the same upgrade, take the opportunity
|
||
to retire any local overrides it was using to brand the
|
||
pre-0.2.0 hardcoded strings — those overrides are now obsolete.
|
||
6. The deployment **MUST NOT** continue to expect graduation step 4
|
||
to fail on Gitea's "Please try again later" race; that path is
|
||
now handled by `wait_for_mergeable` + bounded retry. Any local
|
||
workaround retrying graduation at the deployment level is now
|
||
redundant and **SHOULD** be removed.
|
||
|
||
### Fixed
|
||
|
||
- **Graduation merge race.** §13.3 step 4 (`merge_pr`) used to call
|
||
Gitea's merge endpoint the instant step 3 returned, which races
|
||
Gitea's background mergeability computation and produces the
|
||
`405 "Please try again later"` response. Step 4 now waits for the
|
||
`mergeable` field to become non-null (bounded by a 30s timeout) and
|
||
retries the merge call up to three times on the transient response.
|
||
See `backend/app/gitea.py#wait_for_mergeable` and
|
||
`backend/app/bot.py#_merge_with_retry`.
|
||
|
||
### Changed
|
||
|
||
- `frontend/src/App.jsx` header brand reads `VITE_APP_NAME` instead of
|
||
a hardcoded string.
|
||
- `frontend/src/components/Landing.jsx` H1 reads `VITE_APP_NAME`
|
||
instead of a hardcoded string. The pitch / deck / attribution copy
|
||
in this file is still deployment-specific and tracked as a follow-up
|
||
for extraction into deployment config.
|
||
- `frontend/index.html` `<title>` is rewritten at build time via Vite's
|
||
HTML transform.
|
||
|
||
### Added
|
||
|
||
- `frontend/.env.example` documenting the new required variable.
|
||
- Top-level `VERSION` file and this `CHANGELOG.md` as the canonical
|
||
release log.
|
||
- `SPEC.md` §20 (versioning and downstream deployments) as the
|
||
binding policy for the framework/deployment relationship.
|
||
- `docs/DEPLOYMENTS.md` as the practical guide for building on
|
||
rfc-app and upgrading existing deployments to new versions.
|
||
- `CLAUDE.md` at the repo root capturing the separation-of-concerns
|
||
rule for working sessions.
|
||
|