docs: M3-backend Plan A implementation plan + spec refinements

Adds the bite-sized TDD implementation plan for Plan A (registry mirror +
runtime-config APIs + initial_state/unreviewed semantics; additive only).
Refines the spec with two planning discoveries: the re-stamp must be a
Python startup step (pure SQL can't read DEFAULT_PROJECT_ID), and the
PK-rebuild blast radius justifies splitting M3-backend into Plan A (this)
and Plan B (rebuild + project_id threading + re-stamp, before M4).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-03 22:23:55 -07:00
parent 2cf7db4bff
commit dd72f913a3
2 changed files with 1683 additions and 10 deletions
File diff suppressed because it is too large Load Diff
@@ -50,6 +50,27 @@ directory, the project switcher, the catalog's unreviewed-filter **UI**.
5. **Mirror structure** — a self-contained `app/registry.py` module (not folded 5. **Mirror structure** — a self-contained `app/registry.py` module (not folded
into `cache.py`), driven by the existing webhook dispatcher + the existing into `cache.py`), driven by the existing webhook dispatcher + the existing
`Reconciler.sweep()`. `Reconciler.sweep()`.
6. **Two execution plans (found during planning).** The 12-table PK rebuild is
not self-contained: folding `project_id` into keys + FK forces every
`ON CONFLICT` upsert target to gain `project_id` (~10 sites across 6 modules)
and — because the rebuilt tables can no longer default `project_id` to a live
value once the default is re-stamped — forces **every RFC/branch writer** to
be threaded to supply the real `project_id`. That activation is larger and
riskier than the rest of M3-backend combined, and it is only required *before
a second project can collide* (i.e. right before M4). So M3-backend is split
into two plans at that seam:
- **Plan A (ships first):** registry mirror + the two APIs + `initial_state`/
`unreviewed` semantics. Migration `027` is **additive only** (no rebuilds).
Operates entirely on the `default`-id project — no re-stamp, no rebuild, no
writer threading. `cached_rfcs` keeps its `slug` PK, so no upsert breakage.
- **Plan B (before M4 / before public `/p/` URLs):** the 12-table PK rebuild
(migration `028`), `project_id` threading through every writer, the
`default`→slug **re-stamp** (which rides here because it is only correct
once the rebuild's column-default fix + threading land), and two-project
isolation tests.
The §1 sections below describe the **full** backend (both plans); §1c (the
rebuilds) and §1d (the re-stamp) are **Plan B**. Everything else is Plan A.
--- ---
@@ -89,11 +110,24 @@ CREATE TABLE IF NOT EXISTS deployment (
INSERT OR IGNORE INTO deployment (id) VALUES (1); INSERT OR IGNORE INTO deployment (id) VALUES (1);
``` ```
> **Re-stamp split (correctness, found during planning).** The migration
> runner executes pure-SQL files and **cannot read `DEFAULT_PROJECT_ID` from the
> environment** — the same constraint that forced M1's `seed_default_project`
> into Python. So the re-stamp is **not** in the `.sql` file. Migration `027`
> (pure SQL) does §1a–§1c with `project_id` copied **verbatim** (`default` stays
> `default`); the rebuilt FKs are declared `ON UPDATE CASCADE ON DELETE
> CASCADE`. The re-stamp (§1d) is a **Python startup step** in `app/projects.py`,
> run after migrations and before the registry mirror, that issues a single
> `UPDATE projects SET id = <slug>` (cascading to the 12 FK tables) plus a plain
> `UPDATE` of the 7 non-FK `project_id` tables. Idempotent: a no-op once no
> `default` row remains.
### 1c. PK / uniqueness rebuilds (the 12 deferred tables) ### 1c. PK / uniqueness rebuilds (the 12 deferred tables)
Per migration 026's deferred block, create-copy-drop-rename each table to fold 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 `project_id` into the key and add `project_id … REFERENCES projects(id) ON
DELETE CASCADE`: UPDATE CASCADE ON DELETE CASCADE` (the `ON UPDATE CASCADE` is what lets the
§1d Python re-stamp move all child rows with one parent UPDATE):
| Table | Key change | | Table | Key change |
| --- | --- | | --- | --- |
@@ -116,17 +150,22 @@ distinct per project) — **no rebuild**.
The copy step writes `<slug>` in place of `default` for `project_id`, so these The copy step writes `<slug>` in place of `default` for `project_id`, so these
12 tables are re-stamped for free (§1d). 12 tables are re-stamped for free (§1d).
### 1d. Re-stamp `default``<slug>` ### 1d. Re-stamp `default``<slug>` (Python startup step)
- `UPDATE projects SET id = '<slug>' WHERE id = 'default'` (only when `<slug>` In `app/projects.py`, run at startup after `db.init` and before
`default`). `refresh_registry`. When `DEFAULT_PROJECT_ID` is set and a `default` project row
- The 12 rebuilt tables: re-stamped during their copy (§1c). still exists, in one `db.tx()`:
- 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. - `UPDATE projects SET id = <slug>, updated_at = datetime('now') WHERE id =
'default'` — cascades `project_id` across the 12 FK tables via `ON UPDATE
CASCADE`.
- For the 7 `project_id`-bearing tables with **no** FK (`threads`, `changes`,
`notifications`, `actions`, `pr_resolution_branches`, `rfc_invitations`,
`cached_prs`): `UPDATE <t> SET project_id = <slug> WHERE project_id =
'default'`.
End state: a single `project_id` value DB-wide. Idempotent — a no-op once no
`default` row remains, so it is safe on every boot.
### 1e. Notes ### 1e. Notes