# 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.51.1 — 2026-06-07 **Patch — two §22 correctness fixes (no operator action required).** - **Faceted catalog / bulk bar now scope to the named collection in the URL.** `useCollectionId` read the route param, but the catalog renders above the `c/:collectionId` route, so it always fell back to the default collection — the faceted filter (§22.4a SLICE-3) and bulk action bar (SLICE-5) never scoped to a named, `fields:`-bearing collection in the deployed app. It now resolves the `/c//` segment from the path. Caught by a new end-to-end Playwright suite exercising the metadata UI against a real browser + Gitea. - **Heal the migration-029 vs registry-mirror collection-id divergence.** On a deployment that already held ≥2 projects when migration 029 ran, the default project's collection was seeded as the project id while the mirror expects `'default'`, so the mirror inserted a duplicate empty collection. A new startup reconciler (`projects.reconcile_default_collection_id`, the collection-grain twin of the §22.13 re-stamp) renames the divergent collection to `'default'` before the mirror runs. Idempotent; a no-op on fresh / single-project / already-aligned deployments (incl. the default `document` deployment). Also: the per-IP auth rate-limiter budgets are now env-overridable (`RATELIMIT_OTC_REQUEST_MAX` / `_VERIFY_MAX` / `_CHECK_MAX`) for test/PPE stacks that drive auth from one IP; production keeps the secure defaults. The Tier-1 test harness is brought current with the §22 three-tier app. ## 0.51.0 — 2026-06-07 **Minor — bulk tag/untag metadata edit (§22.4a SLICE-5).** An authorized user can now apply one metadata field change to many catalog entries at once. A new endpoint `POST /api/projects/{id}/collections/{cid}/meta/bulk` takes `{slugs, op: set|add|remove, field, value}`, validates each entry against the collection's `fields:` schema at the write boundary (INV-4), writes the passing entries' `.meta.yaml` **sidecars** in a **single commit** (D7: bulk = one commit, reusing the SLICE-4 sidecar write-through), re-ingests, and returns `{applied, rejected}` so partial failures (missing entry, invalid value) are reported without sinking the batch. `set` works for any field; `add`/`remove` operate on a `tags`-type field. In the faceted catalog, contributors now get a per-row selection checkbox and a sticky bulk action bar (one "Set ▾" control per enum field, add/remove-tag for tags fields); a successful apply toasts the applied/skipped counts and refreshes the list. This completes PUC-2 of the [Configurable Collection Metadata](./docs/design/2026-06-06-configurable-collection-metadata.md) design (§5.3, §6.4/§6.5). No deployment action required. The endpoint and the bulk bar are additive and opt-in per collection: a collection with no `fields:` block (INV-5 — the default `document` collection) shows no selection UI and the endpoint returns `422`, so an N=1 deployment sees zero change. ## 0.50.0 — 2026-06-07 **Minor — single-entry metadata edit + sidecar-aware writes (§22.4a SLICE-4).** An authorized user can now edit one entry's schema-defined metadata directly from its detail view. A new endpoint `POST /api/projects/{id}/collections/{cid}/rfcs/{slug}/meta` takes `{values:{…}}`, validates them against the collection's `fields:` schema at the write boundary (INV-4), writes them to the entry's `.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 `.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 `.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:` + `.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`→`` 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//c//` 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 `-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//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//members/` (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//collections` carries a `viewer` block (`can_create_collection`, `can_invite`, `role`); `GET /api/projects//collections/` 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//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//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//` 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 `/.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//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//collections` and `…/collections/`. - **Collection-scoped serve + propose** — `GET /api/projects//collections//rfcs[/]` and `POST …/collections//rfcs/propose`. A propose writes the entry under the target collection's `/rfcs/`. - **Collection directory at `/p//`** — 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//` 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 `/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//collections` (or commit a `/.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//` 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//` URL segment** — the canonical entry route is now `/p//c//e/`. The project landing `/p//` 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//e/` URLs gain a `/c//` segment.** The shipped v0.35.0 `/p//e/` form is preserved by a client-side redirect into the default collection; the pre-multi-project `/rfc/` and `/proposals/` server 308s now target `/p//c//…`. > ### 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//c/default/e/`. Existing `/p//e/`, > `/rfc/`, and `/proposals/` 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//` 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=` 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 `` 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//` 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//`, 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//` 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/` 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//`), 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/`, `/rfc//pr/`, and `/proposals/` permanently redirect to `/p//e/[…]` / `/p//proposals/` (§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 `RFC` and JS sets the real title after config loads. - **Entry/proposal URLs moved under `/p//`.** 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/` URL **308**-redirects to `/p//e/`; `/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: tagline: projects: - id: default name: type: document content_repo: 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=` 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//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//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 `