From 380e1f9782e3bf9f6d89c554b09a3829c05d15ba Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Thu, 4 Jun 2026 06:12:38 -0700 Subject: [PATCH] =?UTF-8?q?docs(design):=20M3-backend=20Plan=20B=20?= =?UTF-8?q?=E2=80=94=20per-project=20serving,=20default-id=20re-stamp,=20P?= =?UTF-8?q?K=20rebuilds=20(=C2=A722)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The blueprint for the next §22 slice after M3-frontend (v0.35.0): make a second project's corpus actually serve + render. Pins the three things that land together — the default-project-id re-stamp (§22.13 step 1), the deferred slug-keyed PK/UNIQUE rebuilds (migration 026 header, 12 tables), and per-project RFC serving (path-scoped endpoints + per-project cache/bot/webhook dispatch + scoped frontend calls + guard removal). Notes the Tier-1 registry seed as the shared unblock for both this slice's and M3-frontend's deferred e2e. Target v0.36.0 (possibly split B-1 read / B-2 write). Co-Authored-By: Claude Opus 4.8 (1M context) --- .../2026-06-04-m3-backend-planb-design.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md diff --git a/docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md b/docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md new file mode 100644 index 0000000..ad41684 --- /dev/null +++ b/docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md @@ -0,0 +1,186 @@ +# M3-backend Plan B — §22 multi-project: per-project RFC serving, default-id re-stamp, slug-keyed PK rebuilds — design + +**Date:** 2026-06-04 +**Slice:** §22 M3 (the backend half that M3-frontend deferred). Pairs with +**M3-frontend** (`2026-06-03-m3-frontend-design.md`, shipped v0.35.0) which +delivered the shell (routing, runtime branding, directory/switcher, 308s) but +kept RFC *data* calls unscoped, so only the corpus-served default project +renders. This slice makes a **second project's corpus actually serve and +render**, and lands the two §22.13/migration-026 items that must precede +project #2. +**Status:** design, pending plan. Authoritative model: `multi-project-spec.md` +(Part A §22, Part C M3) + `multi-project.md`. +**Target release:** the next pre-1.0 minor (breaking: URL/migration). Likely +**v0.36.0**; may split into two minors (B-1 read path, B-2 write path) — see §7. + +## Goal + +After this slice a deployment with **N≥2** registry projects serves and renders +every project's own corpus under `/p//`, identified by slug *within* the +project (§22.4). The M3-frontend guard (`NotServedPlaceholder` for any +non-default project) is **removed** — the guard contract (`default_project_id`) +stays on `/api/deployment` only as the redirect target for legacy URLs. + +Three things land together because none is safe without the others: + +1. **Default-project-id re-stamp** (§22.13 step 1) — `default` → a config slug. +2. **Slug-keyed PK/UNIQUE rebuilds** (migration 026 header) — fold `project_id` + into the composite keys so a second project can't collide on a slug. +3. **Per-project RFC serving** — endpoints, cache mirror, bot, and webhook + routing all dispatch by project; the frontend calls the scoped routes. + +## 1. Default-project-id re-stamp (§22.13 step 1) + +Today `projects.resolved_default_id()` always returns the literal `"default"` +(Plan A), and M1's backfill stamped every existing row `project_id='default'`. +This slice re-stamps that bootstrap id to a **config-derived slug** so the +deployment's URL is meaningful (`/p/ohm/` not `/p/default/`) and stable. + +- **Resolution order** (already drafted in `resolved_default_id`'s docstring): + `DEFAULT_PROJECT_ID` env var → else slug of the deployment name → else + `default`. Decision: keep it config-explicit — OHM sets + `DEFAULT_PROJECT_ID=ohm` in its overlay; the registry's first project `id` + SHOULD match. +- **Why it must precede public `/p/` URLs:** once `/p//` is linkable, the + id is a permanent URL; renaming it later breaks links. M3-frontend shipped + `/p//` already, so strictly this re-stamp is now *slightly late* — + acknowledge that and treat the re-stamp as a one-time 308-preserving rename + (add `/p/default/* → /p//*` 308s for one release if `DEFAULT_PROJECT_ID` + differs from `default`). Open: do we bother, given OHM isn't deployed on the + multi-project stack yet (pinned 0.31.5)? If OHM cuts over *directly* to a + re-stamped slug, no `default` URL was ever public and the extra 308s are + unnecessary. **Recommendation: re-stamp lands in the SAME deploy OHM first + adopts the registry, so `default` is never public for OHM → no legacy 308 + needed.** Code the re-stamp as part of the registry-mirror reconcile (rename + rows whose `project_id` is the stale bootstrap value to the resolved id), + idempotent. +- **Mechanics:** a migration (or the reconciler, run-once guarded) `UPDATE`s + `project_id` from the old bootstrap value to the resolved slug across the + `projects` row, `project_members`, and every scoped table (the ~19 from + migration 026). Must run **inside the same transaction** as / before the PK + rebuilds (§2) so FKs stay consistent. + +## 2. Slug-keyed PK / UNIQUE rebuilds (migration 026 header) + +SQLite can't `ALTER` a PRIMARY KEY/UNIQUE in place, so each table below is +rebuilt with the create-new → copy → drop-old → rename pattern, inside one +transaction with `PRAGMA foreign_keys=OFF` around it (per the existing +migration-runner convention — confirm in `db.py`). The composite-key targets +(verbatim from `026_projects.sql`): + +| Table | old key | new key | +| --- | --- | --- | +| `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 idx `(rfc_slug, user_id)` | `+project_id` | +| `contribution_requests` | UNIQUE idx `(rfc_slug, requester_user_id) WHERE pending` | `+project_id` | +| `proposed_use_cases` | UNIQUE `(scope, pr_number)` | `+project_id` | + +`cached_prs` UNIQUE `(repo, pr_number)` needs **no** rebuild — `repo` is the +full `org/repo` string, already distinct per project. + +- Every dependent index/trigger/view is recreated against the new table. +- Backfilled rows already carry `project_id` (M1), so the copy is a straight + `INSERT INTO new SELECT * FROM old`. +- Add the FK `project_id REFERENCES projects(id)` on rebuild where it was + deferred. +- **Test:** a migration test that seeds two projects with the *same* slug and + asserts both rows coexist post-rebuild (the collision M1 couldn't allow). + +## 3. Per-project RFC serving — endpoints + +Decision to pin in planning: **path-scoped** routes, mirroring the frontend's +`/p//` and §22.4's `(project_id, slug)` identity: + +``` +GET /api/projects/{pid}/rfcs catalog (replaces GET /api/rfcs) +GET /api/projects/{pid}/rfcs/{slug} entry (replaces /api/rfcs/{slug}) +POST /api/projects/{pid}/rfcs/propose propose (already partly there: + mark-reviewed is /api/projects/{pid}/…) +GET /api/projects/{pid}/proposals[/{n}] idea PRs +…branches / prs / discussion / graduation analogously gain the {pid} prefix. +``` + +- Keep the old unscoped `/api/rfcs*` as **thin shims** that resolve the default + project and delegate, for one release, so a stale frontend bundle mid-deploy + still works; remove in the following minor. (Or hard-cut — decide in planning; + the frontend ships scoped calls in the same release, so the shim is only for + in-flight bundles.) +- Every handler runs the §22.5 read/write gate on `{pid}` + (`require_project_readable`, `can_contribute_in_project`) — the resolver + primitives already exist (M2). The per-RFC lookups change from `WHERE slug=?` + to `WHERE project_id=? AND slug=?`. +- `propose`/graduation already call `projects_mod.resolved_default_id(config)` + (api.py:874) — change to the path `{pid}`. + +## 4. Cache mirror, bot, webhook — dispatch by project + +- **Mirror:** `cache.refresh_meta_repo()` reads one `content_repo` today + (`projects.default_content_repo`). Generalize to iterate **all** projects' + `content_repo`s, mirroring each into `cached_rfcs` (etc.) stamped with that + project's id. Loop over `projects` rows. +- **Bot:** `bot.open_idea_pr(...)` and the branch/PR helpers take a + `project_id` (or a resolved `content_repo`) instead of the default. +- **Webhook (§4):** `/api/webhooks/gitea` maps the pushed repo → + `projects.content_repo` → project, and refreshes only that project's cache + (the planner does exactly this `match_repo` step — mirror its shape). +- **Registry webhook** already reconciles `projects` (Plan A). No change. + +## 5. Frontend — relax the guard, scope the calls + +- `api.js`: `listRFCs`/`getRFC`/`listProposals`/`getProposal`/`proposeRFC`/… + gain a `projectId` arg and hit the `/api/projects/{pid}/…` routes. `useProjectId()` + (already added in M3-frontend, `lib/entryPaths.js`) supplies it. +- `ProjectLayout`: **remove the `served`/`NotServedPlaceholder` guard** — every + registry project now serves. Keep `NotServedPlaceholder` only as the 404/not- + readable surface (or delete and reuse the not-found branch). +- `Catalog`, `RFCView`, `PRView`, `ProposalView`, `ProposeModal` read their + data through the scoped api with `useProjectId()`. +- `/api/deployment.default_project_id` stays (the legacy-URL 308 target). + +## 6. Testing + +- **Migration:** two-project same-slug coexistence (§2); re-stamp idempotence; + FK integrity post-rebuild. +- **Backend vertical:** a second `document` project end-to-end — propose → + super-draft → graduate, identified by slug *in that project*; visibility gate + (gated 2nd project 404s a non-member while the default stays readable); + cross-project isolation (a slug in project A is not found under project B). +- **Frontend unit:** scoped api calls carry the right `pid`; the guard removal + renders a non-default project's catalog. +- **Tier-1 e2e (now unblockable):** seed a **registry repo + `projects.yaml` + with two projects** into the dockerized Gitea and set `REGISTRY_REPO` in + `testing/.env.tier1` (currently empty — this is the blocker M3-frontend's + e2e + this slice's e2e share). Then the M3-frontend Playwright specs (N=1 + redirect, directory with 2+, 308s, switcher) AND a second-project corpus + render become runnable. **Land the Tier-1 registry seed as the first task of + this slice** — it pays off both slices' deferred e2e. + +## 7. Sequencing & risk + +- **Highest risk:** the §2 PK rebuilds (12 table recreates in one migration). + Mitigate with an isolated migration test DB seeded from a realistic dump and + a row-count assertion before/after each table. +- **Possible split:** B-1 = re-stamp + PK rebuilds + read-path serving + (catalog + entry view scoped) — enough for a 2nd project's corpus to *render* + read-only; B-2 = write path (propose/branch/PR/graduate) scoped. Each is + independently shippable behind the N=1 default. Decide in planning; a single + v0.36.0 is fine if the write-path rescope is mechanical. +- **OHM impact:** OHM is pinned 0.31.5 and not yet on the registry stack, so + this slice has **no live deployment to migrate** until the OHM cutover + milestone (ohm-rfc ROADMAP Phase G). Land it on `main`; it deploys to OHM as + part of that cutover. + +## 8. Versioning + +Pre-1.0 minor, breaking (URL move + migration). `VERSION` + +`frontend/package.json` move together (§20.1). CHANGELOG upgrade-steps: +migration runs automatically; old `/api/rfcs*` shims (if kept) deprecated; the +re-stamp note (`DEFAULT_PROJECT_ID`); the SPEC §22 merge stays for M7.