Files
rfc-app/docs/superpowers/specs/2026-06-03-m3-backend-design.md
Ben Stull dd72f913a3 docs: M3-backend Plan A implementation plan + spec refinements
Adds the bite-sized TDD implementation plan for Plan A (registry mirror +
runtime-config APIs + initial_state/unreviewed semantics; additive only).
Refines the spec with two planning discoveries: the re-stamp must be a
Python startup step (pure SQL can't read DEFAULT_PROJECT_ID), and the
PK-rebuild blast radius justifies splitting M3-backend into Plan A (this)
and Plan B (rebuild + project_id threading + re-stamp, before M4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:23:55 -07:00

414 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# M3-backend — §22 multi-project: registry mirror + data spine + APIs
> Design spec for the backend half of §22 slice **M3** ("Registry mirror +
> routing + runtime branding"). The roadmap bundles M3 as one slice; this
> session splits it at the natural backend/frontend seam. **M3-backend** (this
> doc) ships the data spine, the registry mirror, the two runtime-config APIs,
> and the entry-state/review semantics. **M3-frontend** (a separate spec) ships
> `/p/<project>/` routing, the 308 redirects, the `VITE_APP_NAME`→runtime-config
> cut, the per-project theme overlay, the deployment directory at `/`, and the
> project switcher — all consuming the APIs defined here.
>
> Section references `§22.x` point at `docs/design/multi-project-spec.md` (the
> draft §22 + slicing plan). The SPEC.md §22 merge itself lands in M7.
## Status
- **Date:** 2026-06-03
- **Slice:** §22 M3 (backend half). M1 + M2 landed and merged to `main`.
- **Version impact:** minor bump, breaking (pre-1.0) — see §8.
## Goal
After M3-backend, the framework learns its projects from a git **registry**
(not from `META_REPO`), the `projects` cache table and all slug-bearing tables
are keyed by `(project_id, …)` so a second project can exist without collision,
the default project's identity is re-stamped to its real slug while no `/p/`
URL is yet public, and the runtime exposes deployment + project config over two
new endpoints. The entry-state/review semantics (`initial_state`, `unreviewed`)
ship complete even though OHM (a `document`/`super-draft` project) does not yet
exercise them.
Non-goals (M3-frontend, later): `/p/<project>/` routing, 308 redirects off
`/rfc/<slug>`, runtime branding in the UI, theme application, the deployment
directory, the project switcher, the catalog's unreviewed-filter **UI**.
## Decisions taken in brainstorming
1. **Scope split** — backend spine first; frontend surfaces are a separate
spec/session.
2. **Review machinery** — build the full `initial_state` / `unreviewed`
plumbing now (parse + columns + mirror + landing logic + mark-reviewed +
catalog filter query side), per the spec's M3 bundle, even though no live
project exercises it yet.
3. **Config cut** — hard cut. `REGISTRY_REPO` required (loud fail if unset),
`META_REPO` removed. Upgrade-steps document the manual registry creation.
4. **Re-stamp** — rewrite `project_id` everywhere: rename `projects.id`
`default` → the config slug and rewrite every child row, folded into the
same create-copy-drop-rename rebuild that adds the `project_id` FK. One
identifier; DB and URL agree.
5. **Mirror structure** — a self-contained `app/registry.py` module (not folded
into `cache.py`), driven by the existing webhook dispatcher + the existing
`Reconciler.sweep()`.
6. **Two execution plans (found during planning).** The 12-table PK rebuild is
not self-contained: folding `project_id` into keys + FK forces every
`ON CONFLICT` upsert target to gain `project_id` (~10 sites across 6 modules)
and — because the rebuilt tables can no longer default `project_id` to a live
value once the default is re-stamped — forces **every RFC/branch writer** to
be threaded to supply the real `project_id`. That activation is larger and
riskier than the rest of M3-backend combined, and it is only required *before
a second project can collide* (i.e. right before M4). So M3-backend is split
into two plans at that seam:
- **Plan A (ships first):** registry mirror + the two APIs + `initial_state`/
`unreviewed` semantics. Migration `027` is **additive only** (no rebuilds).
Operates entirely on the `default`-id project — no re-stamp, no rebuild, no
writer threading. `cached_rfcs` keeps its `slug` PK, so no upsert breakage.
- **Plan B (before M4 / before public `/p/` URLs):** the 12-table PK rebuild
(migration `028`), `project_id` threading through every writer, the
`default`→slug **re-stamp** (which rides here because it is only correct
once the rebuild's column-default fix + threading land), and two-project
isolation tests.
The §1 sections below describe the **full** backend (both plans); §1c (the
rebuilds) and §1d (the re-stamp) are **Plan B**. Everything else is Plan A.
---
## 1. Migration `027_projects_activate.sql`
Runs while `default` is still the sole project and no `/p/` URL exists — the
safe window for an identity rewrite. One transaction (SQLite DDL is
transactional): the deployment either fully advances to `027` or stays on `026`.
`DEFAULT_PROJECT_ID` is read at migration time, so it **must be set before the
upgrade deploy** (documented in §8). The slug it names is referred to below as
`<slug>`; absent the env var, `<slug>` stays `default`.
### 1a. Additive columns
- `projects`:
- `type TEXT NOT NULL DEFAULT 'document' CHECK (type IN ('document','specification','bdd'))`
- `initial_state TEXT NOT NULL DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active'))`
- `cached_rfcs`:
- `unreviewed INTEGER NOT NULL DEFAULT 0`
- `reviewed_at TEXT`
- `reviewed_by TEXT`
### 1b. New `deployment` singleton table
Holds deployment-level identity mirrored from the registry's `deployment:`
block (rather than overloading `projects`):
```sql
CREATE TABLE IF NOT EXISTS deployment (
id INTEGER PRIMARY KEY CHECK (id = 1),
name TEXT,
tagline TEXT,
registry_sha TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO deployment (id) VALUES (1);
```
> **Re-stamp split (correctness, found during planning).** The migration
> runner executes pure-SQL files and **cannot read `DEFAULT_PROJECT_ID` from the
> environment** — the same constraint that forced M1's `seed_default_project`
> into Python. So the re-stamp is **not** in the `.sql` file. Migration `027`
> (pure SQL) does §1a–§1c with `project_id` copied **verbatim** (`default` stays
> `default`); the rebuilt FKs are declared `ON UPDATE CASCADE ON DELETE
> CASCADE`. The re-stamp (§1d) is a **Python startup step** in `app/projects.py`,
> run after migrations and before the registry mirror, that issues a single
> `UPDATE projects SET id = <slug>` (cascading to the 12 FK tables) plus a plain
> `UPDATE` of the 7 non-FK `project_id` tables. Idempotent: a no-op once no
> `default` row remains.
### 1c. PK / uniqueness rebuilds (the 12 deferred tables)
Per migration 026's deferred block, create-copy-drop-rename each table to fold
`project_id` into the key and add `project_id … REFERENCES projects(id) ON
UPDATE CASCADE ON DELETE CASCADE` (the `ON UPDATE CASCADE` is what lets the
§1d Python re-stamp move all child rows with one parent UPDATE):
| Table | Key change |
| --- | --- |
| `cached_rfcs` | PK `(slug)``(project_id, slug)` |
| `cached_branches` | UNIQUE `(rfc_slug, branch_name)``+project_id` |
| `branch_visibility` | UNIQUE `(rfc_slug, branch_name)``+project_id` |
| `branch_contribute_grants` | UNIQUE `(rfc_slug, branch_name, grantee_user_id)``+project_id` |
| `stars` | UNIQUE `(user_id, rfc_slug)``+project_id` |
| `watches` | UNIQUE `(user_id, rfc_slug)``+project_id` |
| `pr_seen` | UNIQUE `(user_id, rfc_slug, pr_number)``+project_id` |
| `branch_chat_seen` | UNIQUE `(user_id, rfc_slug, branch_name)``+project_id` |
| `funder_consents` | PK `(user_id, rfc_slug)``+project_id` |
| `rfc_collaborators` | UNIQUE INDEX `(rfc_slug, user_id)``+project_id` |
| `contribution_requests` | UNIQUE INDEX `(rfc_slug, requester_user_id) WHERE pending``+project_id` |
| `proposed_use_cases` | UNIQUE `(scope, pr_number)``+project_id` |
`cached_prs` is already globally unique (`repo` is the full `org/repo` string,
distinct per project) — **no rebuild**.
The copy step writes `<slug>` in place of `default` for `project_id`, so these
12 tables are re-stamped for free (§1d).
### 1d. Re-stamp `default` → `<slug>` (Python startup step)
In `app/projects.py`, run at startup after `db.init` and before
`refresh_registry`. When `DEFAULT_PROJECT_ID` is set and a `default` project row
still exists, in one `db.tx()`:
- `UPDATE projects SET id = <slug>, updated_at = datetime('now') WHERE id =
'default'` — cascades `project_id` across the 12 FK tables via `ON UPDATE
CASCADE`.
- For the 7 `project_id`-bearing tables with **no** FK (`threads`, `changes`,
`notifications`, `actions`, `pr_resolution_branches`, `rfc_invitations`,
`cached_prs`): `UPDATE <t> SET project_id = <slug> WHERE project_id =
'default'`.
End state: a single `project_id` value DB-wide. Idempotent — a no-op once no
`default` row remains, so it is safe on every boot.
### 1e. Notes
- Migration `016` is absent from the on-disk sequence (`015 → 017`); the runner
already tolerates the gap (the app runs today). **Do not renumber.** New file
is `027`.
- `PRAGMA foreign_keys` is honored going forward; the FK lands on the 12 rebuilt
tables. The other 7 keep app-layer integrity (matching today's posture for
non-rebuilt tables).
---
## 2. Registry format + `app/registry.py`
### 2a. `projects.yaml` (root of `REGISTRY_REPO`)
```yaml
deployment:
name: Open Human Model # replaces VITE_APP_NAME (M3-frontend consumes)
tagline: ...
projects:
- id: ohm # url-stable slug, unique in the deployment
name: Open Human Model
type: document # document | specification | bdd — immutable
content_repo: ohm # repo under the deployment's Gitea org
visibility: public # gated | public | unlisted
initial_state: super-draft # optional; defaults from type
enabled_models: [claude, gemini] # optional; falls back to ENABLED_MODELS
theme: { accent: "#5b5bd6" } # optional; M3-frontend consumes
```
### 2b. `refresh_registry(config, gitea) -> RegistryResult`
The config-side analogue of `cache.refresh_meta_repo`. Fetches `projects.yaml`
from `REGISTRY_REPO` at HEAD, parses, **validates**, and upserts:
- Each project → `projects` row: `id, name, type, content_repo, visibility,
initial_state`, plus `config_json` (JSON blob for `theme`, `enabled_models`),
plus `registry_sha` (commit SHA, provenance).
- The `deployment:` block → the `deployment` singleton (`name`, `tagline`,
`registry_sha`).
Projects present in the table but absent from the registry are **not** deleted
in M3-backend (archival semantics are out of scope; a removed entry simply
stops being refreshed). This is noted as a known limitation; revisit if/when
project archival is specced.
### 2c. Validation (loud)
Per the framework's separation-of-concerns rule, malformed config fails
visibly rather than shipping wrong content silently:
- Each project requires `id`, `name`, `type`, `content_repo`.
- `type` ∈ {`document`,`specification`,`bdd`}; `visibility` ∈
{`gated`,`public`,`unlisted`}.
- `initial_state` defaults from `type` when omitted: `document`/`specification`
→ `super-draft`, `bdd` → `active`. When present it must be a valid §2.4
super-draft entry-state value.
- `id` values unique and slug-shaped (`^[a-z0-9][a-z0-9-]*$`).
- **`type` is immutable:** an incoming `type` differing from the stored row's is
rejected (the entry is skipped, the rest proceed; logged loudly).
- **Default-id consistency:** the re-stamped default id (`DEFAULT_PROJECT_ID`,
else `default`) MUST appear as an `id` in the registry, or the registry is
inconsistent with config → surfaced loudly.
### 2d. Wiring (Option A)
- **Webhook** (`app/webhooks.py`): add a branch — if the pushed repo
`full_name` matches `REGISTRY_REPO`, call `registry.refresh_registry(...)`.
Same HMAC-verified `/api/webhooks/gitea` dispatcher; no new endpoint. Add
`REGISTRY_REPO` to the set of repos the dispatcher recognizes.
- **Sweep** (`cache.Reconciler.sweep()`): add one `await
registry.refresh_registry(...)` at the top of each pass, so the safety-net
loop keeps `projects` in sync if a webhook is missed.
- **Startup** (`main.py` lifespan): run `refresh_registry` once after
migrations. This **replaces** M1's `seed_default_project` (which is removed).
### 2e. Failure posture
- **Startup / first boot:** if `REGISTRY_REPO` is unset/unreachable, or
`projects.yaml` is missing or fails validation, the app **fails loudly**
(refuses to start) — there is no last-known-good to serve.
- **Running deployment:** a malformed `projects.yaml` pushed in a later PR is
logged and **skipped**, leaving the last-good `projects` rows intact — a bad
config PR must not take the deployment down. This mirrors how the corpus
reconciler tolerates a bad content push today.
---
## 3. Config (`app/config.py`)
- `registry_repo`: **required** — construction fails loudly if unset (matching
the other required vars). Add `registry_repo_full` → `{gitea_org}/{registry_repo}`.
- `meta_repo` and `meta_repo_full`: **removed**.
- `default_project_id`: optional; consumed by migration `027` (re-stamp) and by
`refresh_registry` (the §2c consistency gate).
- `enabled_models`: unchanged — the deployment-level fallback for a project's
optional `enabled_models`.
- `app/projects.py`: `seed_default_project` retired (superseded by the mirror).
`DEFAULT_PROJECT_ID` constant and resolution helpers retained as needed.
---
## 4. APIs — `app/api_deployment.py`
A new sub-router mounted in `app/api.py`.
### `GET /api/deployment`
Returns `{ name, tagline, projects: [...] }`. The deployment `name`/`tagline`
come from the `deployment` singleton. The project list is filtered by caller
visibility (§22.5):
- `public` projects → visible to everyone (incl. anonymous).
- `gated` projects → only when the caller is a member (`visible_project_ids`
from M2's resolver).
- `unlisted` projects → **omitted entirely** (reachable only by direct id).
Each item: `{ id, name, type, visibility }` — enough for the M3-frontend
directory + switcher. (Theme is fetched per project.)
### `GET /api/projects/:id`
Returns `{ id, name, tagline, type, visibility, initial_state, theme }`.
Guarded by `require_project_readable(user, id)` — 404 for a non-member of a
gated project, reusing the M2 resolver. `unlisted` is readable here by direct
id (it is hidden only from enumeration).
---
## 5. Entry-state & review semantics
### 5a. Frontmatter (`app/entry.py`)
Parse three new fields, lenient (default `unreviewed=false`, nulls):
`unreviewed: bool`, `reviewed_at`, `reviewed_by`. Add to the `Entry`
dataclass. These are git-truth (§2 frontmatter) so they survive a cache
rebuild, exactly like `state`.
### 5b. Cache mirror (`app/cache.py`)
`_upsert_cached_rfc` writes the three fields into `cached_rfcs`
(`unreviewed`, `reviewed_at`, `reviewed_by`).
### 5c. Entry-landing path
When a creating idea-PR merges (§2.4), resolve the project's `initial_state`:
- `super-draft` → today's behavior unchanged (propose → super-draft → graduate).
- `active` → land the entry `active` with `unreviewed = true`, skipping the §13
graduate gate. The exact merge-handling site (in the PR-merge reconcile path)
is located during implementation.
### 5d. Mark-reviewed
`POST /api/projects/:pid/rfcs/:slug/mark-reviewed`. Authority:
`is_project_superuser` (project_admin or deployment owner/admin) — the same tier
that graduates an entry. Effect: the bot writes `unreviewed: false` +
`reviewed_at`/`reviewed_by` into the entry frontmatter (a git commit, paralleling
graduate), which the mirror then reflects into `cached_rfcs`. Stamps provenance
paralleling `graduated_at`/`graduated_by`.
### 5e. Catalog filter (query side)
`GET /api/rfcs` (and the project-scoped form) gains an `unreviewed=true` query
param that filters on the cached column — the owner's worklist. The UI for it
is M3-frontend; only the query side ships here. `unreviewed` applies to
`active` entries only.
---
## 6. Testing
- **Migration `027`:** seed a `026`-shaped DB with rows under
`project_id='default'`; run `027`; assert: new columns present; the 12 tables
rebuilt with composite keys + FK; all 19 `project_id`-bearing tables
re-stamped to `<slug>`; row counts preserved; FK integrity on; idempotent
re-run is a no-op.
- **`registry.py`:** valid `projects.yaml` upserts all fields + `registry_sha`;
each validation failure rejected (bad enum, missing field, dup id, `type`
mutation, missing default id); `initial_state` type-default applied;
startup-strict vs running-tolerant posture.
- **APIs:** `/api/deployment` visibility filtering across
gated/public/unlisted × member/non-member/anonymous; `/api/projects/:id` 404
gate for gated non-member, 200 for unlisted-by-id.
- **Review flow:** `initial_state: active` lands `unreviewed=true`;
mark-reviewed authority (allow superuser, deny others) + frontmatter write +
mirror reflection; catalog `unreviewed` filter returns the worklist.
- **Two-project isolation:** extend `test_multi_project_authz_vertical.py` with
a genuine **second** registry project sharing a slug with the first, proving
the PK rebuilds isolate them (the core point of M3's activation).
---
## 7. File-touch summary
**New**
- `backend/migrations/027_projects_activate.sql`
- `backend/app/registry.py`
- `backend/app/api_deployment.py`
- tests: `backend/tests/test_migration_027.py`, `test_registry.py`,
`test_api_deployment.py`, `test_review_flow.py`; extend
`test_multi_project_authz_vertical.py`
**Modified**
- `backend/app/config.py` (registry_repo required; meta_repo removed;
default_project_id)
- `backend/app/webhooks.py` (registry-repo branch)
- `backend/app/cache.py` (`Reconciler.sweep` registry call;
`_upsert_cached_rfc` review fields)
- `backend/app/entry.py` (frontmatter fields)
- `backend/app/api.py` (mount `api_deployment`; `unreviewed` filter on
`/api/rfcs`)
- `backend/app/main.py` (startup `refresh_registry`; drop `seed_default_project`)
- `backend/app/projects.py` (retire `seed_default_project`)
- `backend/.env.example` (`REGISTRY_REPO`, `DEFAULT_PROJECT_ID`; remove
`META_REPO`)
---
## 8. Versioning & upgrade
Minor bump, breaking (pre-1.0). `VERSION` + `frontend/package.json#version` move
together (§20). `CHANGELOG.md` gets a breaking entry with an **upgrade-steps**
block:
1. Create a registry repo under the deployment's Gitea org.
2. Author `projects.yaml`: a `deployment:` block (`name`, `tagline`) and one
`projects:` entry for the existing corpus — `id: <slug>`, `name`, `type:
document`, `content_repo: <old META_REPO value>`, `visibility: public`.
3. Set env: `REGISTRY_REPO=<registry repo name>`, `DEFAULT_PROJECT_ID=<slug>`
(must equal the entry's `id`); remove `META_REPO`.
4. Deploy. Migration `027` runs the rebuilds + re-stamp; `refresh_registry`
reconciles the registry into `projects`. Verify `/api/deployment` returns the
project and `/api/health` is green.
The SPEC.md §22 merge stays in M7 per the slicing plan; this slice references the
draft at `docs/design/multi-project-spec.md`.
## Known limitations / deferred
- **Project archival/deletion** from the registry is not handled (a removed
entry stops refreshing but its rows persist). Defer to a future archival spec.
- All routing, redirects, runtime branding, theme application, directory, and
switcher are **M3-frontend**.