Files
rfc-app/docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md
T
Ben Stull 380e1f9782 docs(design): M3-backend Plan B — per-project serving, default-id re-stamp, PK rebuilds (§22)
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) <noreply@anthropic.com>
2026-06-04 06:12:38 -07:00

10 KiB

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/<id>/, 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/<id>/ is linkable, the id is a permanent URL; renaming it later breaks links. M3-frontend shipped /p/<default>/ 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/<slug>/* 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) UPDATEs 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/<project>/ 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_repos, 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.