Lands the table rebuilds migration 026 deferred until a second project exists, shipped separately from per-project serving for migration hygiene. No behavior change (deployments stay single-project 'default'). - migration 028_project_scoped_keys.sql: folds project_id into the slug-keyed PK/UNIQUE of 13 tables (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 gain project_id) and makes the FKs to cached_rfcs on rfc_invitations/rfc_collaborators/contribution_requests composite (project_id, rfc_slug) -> cached_rfcs(project_id, slug). - db.run_migrations: a `-- migrate:no-foreign-keys` migration runs with PRAGMA foreign_keys toggled OFF around it (required by SQLite's table-rebuild procedure) + foreign_key_check after, failing loudly on dangling refs. - ON CONFLICT upsert targets for the rebuilt tables gain project_id so they match the new composite indexes (value still defaults to 'default'). - test_migration_028_project_scoped_keys.py: proves two-project same-slug coexistence, within-project uniqueness, composite-FK enforcement. 442 pass. - design doc §2 marked shipped. Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
11 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:
- Default-project-id re-stamp (§22.13 step 1) —
default→ a config slug. - Slug-keyed PK/UNIQUE rebuilds (migration 026 header) — fold
project_idinto the composite keys so a second project can't collide on a slug. - 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_IDenv var → else slug of the deployment name → elsedefault. Decision: keep it config-explicit — OHM setsDEFAULT_PROJECT_ID=ohmin its overlay; the registry's first projectidSHOULD 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 ifDEFAULT_PROJECT_IDdiffers fromdefault). 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, nodefaultURL was ever public and the extra 308s are unnecessary. Recommendation: re-stamp lands in the SAME deploy OHM first adopts the registry, sodefaultis never public for OHM → no legacy 308 needed. Code the re-stamp as part of the registry-mirror reconcile (rename rows whoseproject_idis the stale bootstrap value to the resolved id), idempotent. - Mechanics: a migration (or the reconciler, run-once guarded)
UPDATEsproject_idfrom the old bootstrap value to the resolved slug across theprojectsrow,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)
STATUS: SHIPPED — rfc-app v0.36.0 (Session 0071.0). Migration
028lands the rebuilds (13 tables, incl. composite FKs onrfc_invitations/rfc_collaborators/contribution_requests→cached_rfcs(project_id, slug)), the migration-runner-- migrate:no-foreign-keyscapability, and theON CONFLICTtarget updates. 442 backend tests green; two-project same-slug coexistence proven (test_migration_028_project_scoped_keys.py). No behavior change. Remaining Plan B = the §1 re-stamp + §3–§5 per-project serving.
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 straightINSERT 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 fromWHERE slug=?toWHERE project_id=? AND slug=?. propose/graduation already callprojects_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 onecontent_repotoday (projects.default_content_repo). Generalize to iterate all projects'content_repos, mirroring each intocached_rfcs(etc.) stamped with that project's id. Loop overprojectsrows. - Bot:
bot.open_idea_pr(...)and the branch/PR helpers take aproject_id(or a resolvedcontent_repo) instead of the default. - Webhook (§4):
/api/webhooks/giteamaps the pushed repo →projects.content_repo→ project, and refreshes only that project's cache (the planner does exactly thismatch_repostep — 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 aprojectIdarg and hit the/api/projects/{pid}/…routes.useProjectId()(already added in M3-frontend,lib/entryPaths.js) supplies it.ProjectLayout: remove theserved/NotServedPlaceholderguard — every registry project now serves. KeepNotServedPlaceholderonly as the 404/not- readable surface (or delete and reuse the not-found branch).Catalog,RFCView,PRView,ProposalView,ProposeModalread their data through the scoped api withuseProjectId()./api/deployment.default_project_idstays (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
documentproject 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.yamlwith two projects into the dockerized Gitea and setREGISTRY_REPOintesting/.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.