# 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) `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)

> **STATUS: SHIPPED — rfc-app v0.36.0 (Session 0071.0).** Migration `028` lands
> the rebuilds (13 tables, incl. composite FKs on `rfc_invitations`/
> `rfc_collaborators`/`contribution_requests` → `cached_rfcs(project_id, slug)`),
> the migration-runner `-- migrate:no-foreign-keys` capability, and the
> `ON CONFLICT` target 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 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_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.
