docs: M3-backend design spec (§22 registry mirror + data spine + APIs)

Brainstormed design for the backend half of §22 slice M3, split at the
backend/frontend seam. Covers migration 027 (type/initial_state columns,
the 12-table PK rebuilds, default→slug re-stamp), the app/registry.py
mirror over REGISTRY_REPO, GET /api/deployment + /api/projects/:id, and
the initial_state/unreviewed review semantics. Routing, runtime branding,
directory, and switcher are deferred to a later M3-frontend spec.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-03 21:59:20 -07:00
parent 7703fa233a
commit 6f356d3598
@@ -0,0 +1,374 @@
# 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()`.
---
## 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);
```
### 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
DELETE CASCADE`:
| 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>`
- `UPDATE projects SET id = '<slug>' WHERE id = 'default'` (only when `<slug>`
`default`).
- The 12 rebuilt tables: re-stamped during their copy (§1c).
- The remaining 7 tables that carry `project_id` but need no key change get a
plain rewrite: `UPDATE <t> SET project_id = '<slug>' WHERE project_id =
'default'` for `threads`, `changes`, `notifications`, `actions`,
`pr_resolution_branches`, `rfc_invitations`, `cached_prs`.
End state: a single `project_id` value DB-wide.
### 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**.