Compare commits

..

6 Commits

Author SHA1 Message Date
Ben Stull 455ef33b29 Merge pull request '§22 M3-backend Plan B (2/2, read): per-project RFC serving (v0.37.0)' (#15) from feat/m3-backend-planb2 into main 2026-06-04 13:45:52 +00:00
Ben Stull 9f548a340d §22 M3-backend Plan B (2/2, read path): per-project RFC serving (v0.37.0)
A second project's corpus now renders under /p/<id>/, isolated by its own
slug namespace. Completes step (1) "RFC app supports multiple projects" for
the read path.

- api.py: GET /api/projects/{pid}/rfcs (scoped catalog) + /{slug} (entry by
  (project_id,slug)), behind the §22.5 read gate. Unscoped /api/rfcs stay as
  default-project compat.
- cache.py: refresh_meta_repo iterates every projects row, mirroring each
  content_repo into cached_rfcs stamped with its project_id (_upsert_cached_rfc
  gains a project_id arg).
- frontend: api.listRFCs(projectId)/getRFC(projectId,slug); Catalog + RFCView
  pass useProjectId(); ProjectLayout's NotServedPlaceholder guard removed (every
  project serves), 404/not-readable branch kept; NotServedPlaceholder deleted.
- tests: test_project_scoped_serving.py (catalog scoping, entry isolation,
  gated 404); ProjectLayout.test updated (non-default renders). 445 backend +
  11 Vitest green; clean build.

Known limitation (next slice): write path + default-id re-stamp not yet scoped
(non-default project read-only); no live impact (no 2nd project in prod yet).
Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 06:45:42 -07:00
Ben Stull 569066ef48 Merge pull request '§22 M3-backend Plan B (1/2): slug-keyed PK rebuilds (v0.36.0)' (#14) from feat/m3-backend-planb into main 2026-06-04 13:30:48 +00:00
Ben Stull a117fbb521 §22 M3-backend Plan B (1/2): slug-keyed PK rebuilds activate project #2 (v0.36.0)
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>
2026-06-04 06:30:37 -07:00
Ben Stull d5213fc2da Merge pull request 'docs(design): M3-backend Plan B design (§22)' (#13) from docs/m3-backend-planb-design into main 2026-06-04 13:12:47 +00:00
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
21 changed files with 897 additions and 73 deletions
+87
View File
@@ -23,6 +23,93 @@ skip versions are the composition of each intervening adjacent
release's steps in order — no A-to-B path is pre-computed beyond
that.
## 0.37.0 — 2026-06-04
**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
second project's corpus now renders under `/p/<id>/`, isolated by its own slug
namespace. No upgrade steps; single-project deployments are unaffected.**
This is the slice that makes "multiple projects" *observable*: M3-frontend
(v0.35.0) shipped the shell with a "not served" guard for any non-default
project; v0.36.0 rebuilt the keys so project #2 can exist; this serves project
#2's corpus.
Added:
- **Per-project read endpoints** (`app/api.py`): `GET /api/projects/{pid}/rfcs`
(catalog scoped to one project) and `GET /api/projects/{pid}/rfcs/{slug}`
(entry by `(project_id, slug)`), both behind the §22.5 read gate
(gated project 404s a non-member). The unscoped `/api/rfcs[/{slug}]` remain as
the default-project compat path.
- **Per-project corpus mirror** (`app/cache.py`): `refresh_meta_repo` now
iterates every `projects` row and mirrors each project's `content_repo` into
`cached_rfcs` stamped with that `project_id` (was: the default project only).
- **Tests:** `test_project_scoped_serving.py` — catalog scoping, entry
isolation (same slug under two projects resolves distinctly; a slug present
only in one 404s under the other), gated-project 404. 445 backend green.
Changed:
- **Frontend** reads the scoped routes: `api.listRFCs(projectId)` /
`getRFC(projectId, slug)`; `Catalog` + `RFCView` pass `useProjectId()`. The
**M3-frontend `NotServedPlaceholder` guard is removed** — every registry
project renders its corpus. `ProjectLayout` keeps the 404/not-readable branch.
Known limitation (next slice): the **write** path (propose / branch / PR /
graduate) and the **default-project-id re-stamp** (§22.13 step 1) are not yet
project-scoped — write affordances still target the default project, so a
non-default project is effectively read-only until Plan B's write slice. (No
live impact: no deployment runs a second project yet.) Per
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3 (write).
## 0.36.0 — 2026-06-04
**Minor — §22 M3-backend Plan B (1/2): the slug-keyed PK/UNIQUE rebuild that
activates project #2. No behavior change (deployments are still single-project
`default`); migration `028` runs automatically on deploy.**
This lands the table rebuilds migration 026 deliberately deferred "until a
second project exists" — folding `project_id` into the slug-keyed PRIMARY KEY /
UNIQUE constraints so two projects can hold the same slug without colliding.
Shipped separately from per-project RFC *serving* (Plan B 2/2) for migration
hygiene: the schema change deploys and is verified on its own, smaller blast
radius.
Added:
- **Migration `028_project_scoped_keys.sql`** — rebuilds 13 tables to composite
slug keys: `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` all gain
`project_id`; and the FKs **to** `cached_rfcs(slug)` on `rfc_invitations`,
`rfc_collaborators`, `contribution_requests` become composite
`(project_id, rfc_slug) → cached_rfcs(project_id, slug)`. (`cached_prs` is
unchanged — `(repo, pr_number)` is already globally unique.)
- **Migration-runner capability** (`db.run_migrations`): a migration whose
first line is `-- migrate:no-foreign-keys` runs with `PRAGMA foreign_keys`
toggled OFF around it (required by SQLite's table-rebuild procedure, and a
no-op inside a transaction) and a `PRAGMA foreign_key_check` after that fails
the migration loudly on any dangling reference.
Changed:
- The `ON CONFLICT(...)` upsert targets for the rebuilt tables gain `project_id`
(`cache.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`,
`api.py`, `funder.py`) so they continue to match the new composite indexes.
The inserted `project_id` still defaults to `default`, so behavior is
identical for a single-project deployment.
Upgrade steps:
1. **MUST** deploy. Migration `028` runs automatically at startup; it rewrites
the listed tables in one transaction with FK enforcement off and verifies
`foreign_key_check` after. No data is dropped (rows already carry
`project_id` from migration 026, M1). No config change.
2. **MAY** note: per-project RFC *serving* (path-scoped endpoints + the
default-id re-stamp + the frontend guard removal) is the next slice (Plan B
2/2), per `docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md`.
## 0.35.0 — 2026-06-04
**Minor (breaking) — §22 M3 frontend: `/p/<project>/` routing, runtime
+1 -1
View File
@@ -1 +1 @@
0.35.0
0.37.0
+83 -1
View File
@@ -708,6 +708,88 @@ def make_router(
payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload
# ---------------------------------------------------------------
# §22.4 (Plan B): per-project RFC serving — the catalog + entry view
# scoped to one project, identified by its own slug namespace
# (project_id, slug). The unscoped /api/rfcs[/{slug}] above stay as the
# default-project compat path; the frontend reads these scoped routes so a
# second project's corpus renders under /p/<id>/.
# ---------------------------------------------------------------
@router.get("/api/projects/{project_id}/rfcs")
async def list_project_rfcs(
project_id: str, request: Request, unreviewed: str | None = None
) -> dict[str, Any]:
viewer = auth.current_user(request)
# §22.5 read gate: a gated project's catalog 404s to a non-member.
auth.require_project_readable(viewer, project_id)
viewer_id = viewer.user_id if viewer else None
unreviewed_clause = ""
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
rows = db.conn().execute(
f"""
SELECT slug, title, state, rfc_id, repo,
owners_json, arbiters_json, tags_json,
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
AND project_id = ?{unreviewed_clause}
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
""",
(project_id,),
).fetchall()
starred = set()
if viewer_id is not None:
starred = {
r["rfc_slug"]
for r in db.conn().execute(
"SELECT rfc_slug FROM stars WHERE user_id = ? AND project_id = ?",
(viewer_id, project_id),
)
}
items = [
{
"slug": r["slug"],
"title": r["title"],
"state": r["state"],
"id": r["rfc_id"],
"repo": r["repo"],
"owners": json.loads(r["owners_json"] or "[]"),
"arbiters": json.loads(r["arbiters_json"] or "[]"),
"tags": json.loads(r["tags_json"] or "[]"),
"last_active_at": r["last_main_commit_at"] or r["last_entry_commit_at"] or r["updated_at"],
"starred_by_me": r["slug"] in starred,
"has_open_prs": False,
}
for r in rows
]
return {"items": items}
@router.get("/api/projects/{project_id}/rfcs/{slug}")
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?",
(project_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
raise HTTPException(404, "Not found")
payload = _serialize_rfc(row)
uc = db.conn().execute(
"""
SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ? AND project_id = ?
ORDER BY id DESC LIMIT 1
""",
(slug, project_id),
).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload
# ---------------------------------------------------------------
# §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag
# ---------------------------------------------------------------
@@ -934,7 +1016,7 @@ def make_router(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('rfc', ?, ?, ?)
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
+2 -2
View File
@@ -742,7 +742,7 @@ def make_router(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, ?, ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
read_public = excluded.read_public,
contribute_mode = excluded.contribute_mode
""",
@@ -896,7 +896,7 @@ def make_router(
"""
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, user_id, rfc_slug, branch_name) DO UPDATE SET
last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at
""",
+1 -1
View File
@@ -222,7 +222,7 @@ def make_router(config: Config) -> APIRouter:
"""
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
ON CONFLICT(user_id, rfc_slug) DO UPDATE SET
ON CONFLICT(project_id, user_id, rfc_slug) DO UPDATE SET
state = excluded.state,
set_by = 'explicit',
set_at = excluded.set_at
+3 -3
View File
@@ -153,7 +153,7 @@ def make_router(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, 1, 'just-me')
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET read_public = 1
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
""",
(slug, branch),
)
@@ -189,7 +189,7 @@ def make_router(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('pr', ?, ?, ?)
ON CONFLICT(scope, pr_number) DO UPDATE SET use_case = excluded.use_case
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
@@ -398,7 +398,7 @@ def make_router(
INSERT INTO pr_seen
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(user_id, rfc_slug, pr_number) DO UPDATE SET
ON CONFLICT(project_id, user_id, rfc_slug, pr_number) DO UPDATE SET
last_seen_commit_sha = excluded.last_seen_commit_sha,
last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at
+37 -23
View File
@@ -35,20 +35,29 @@ log = logging.getLogger(__name__)
async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
"""Re-read rfcs/ on the meta repo and reconcile cached_rfcs.
"""Re-read rfcs/ on every project's content repo and reconcile cached_rfcs.
Idempotent. Safe to call on every meta-repo webhook and on every
reconciler sweep.
§22 (Plan B): a deployment has N projects (§22.1), each with its own
content_repo (§22.3). Mirror each into cached_rfcs stamped with that
project's id, so a second project's corpus renders under /p/<id>/. Idempotent;
safe on every content-repo webhook and reconciler sweep.
"""
org = config.gitea_org
repo = projects_mod.default_content_repo(config)
if not repo:
log.warning("refresh_meta_repo: default project has no content_repo yet; skipping")
rows = db.conn().execute(
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
).fetchall()
if not rows:
log.warning("refresh_meta_repo: no projects with a content_repo yet; skipping")
return
for prow in rows:
await _refresh_project_corpus(org, prow["id"], prow["content_repo"], gitea)
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
try:
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
except GiteaError as e:
log.warning("refresh_meta_repo: cannot list rfcs/: %s", e)
log.warning("refresh_meta_repo: project %s: cannot list rfcs/: %s", project_id, e)
return
seen_slugs: set[str] = set()
@@ -62,24 +71,28 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
try:
entry = entry_mod.parse(text)
except Exception as parse_err:
log.warning("refresh_meta_repo: skipping %s: %s", f["path"], parse_err)
log.warning("refresh_meta_repo: %s: skipping %s: %s", project_id, f["path"], parse_err)
continue
if not entry.slug:
log.warning("refresh_meta_repo: skipping %s: missing slug", f["path"])
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
continue
seen_slugs.add(entry.slug)
_upsert_cached_rfc(entry, body_sha=sha)
_upsert_cached_rfc(entry, body_sha=sha, project_id=project_id)
# Mark entries removed from the meta repo as withdrawn-without-trace.
# In practice the spec keeps withdrawn entries in rfcs/ as historical
# record (§3), so this branch fires only for entries deleted out of
# band. We leave the row but flag it for reconciler attention.
existing = {row["slug"] for row in db.conn().execute("SELECT slug FROM cached_rfcs")}
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
# as historical record (§3), so this fires only for out-of-band deletes;
# leave the row, scoped to this project, for reconciler attention.
existing = {
row["slug"]
for row in db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE project_id = ?", (project_id,)
)
}
for missing in existing - seen_slugs:
log.info("refresh_meta_repo: %s no longer in rfcs/ — leaving cache row in place", missing)
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing)
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str = "default") -> None:
# §6.6: models_json stays NULL when the frontmatter key is absent
# (inherit operator universe) and '[]' for the explicit opt-out.
models_json = json.dumps(entry.models) if entry.models is not None else None
@@ -92,10 +105,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
models_json, funder_login, body, body_sha,
unreviewed, reviewed_at, reviewed_by,
unreviewed, reviewed_at, reviewed_by, project_id,
last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(slug) DO UPDATE SET
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(project_id, slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
rfc_id = excluded.rfc_id,
@@ -137,6 +150,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str) -> None:
1 if entry.unreviewed else 0,
entry.reviewed_at,
entry.reviewed_by,
project_id,
),
)
@@ -196,7 +210,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
@@ -371,7 +385,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
@@ -393,7 +407,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?)
ON CONFLICT(rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
last_commit_at = excluded.last_commit_at
""",
+23 -1
View File
@@ -48,7 +48,29 @@ def run_migrations(config: Config) -> None:
if version in applied:
continue
sql = path.read_text()
conn.executescript("BEGIN; " + sql + "; COMMIT;")
if "-- migrate:no-foreign-keys" in sql:
# SQLite table rebuilds (changing a PRIMARY KEY / UNIQUE, e.g.
# folding project_id into a composite key) follow the official
# 12-step ALTER procedure, which requires FK enforcement OFF —
# and `PRAGMA foreign_keys` is a no-op *inside* a transaction, so
# it must be toggled here, around the script. The connection is
# in autocommit mode (isolation_level=None), so the PRAGMA takes
# effect immediately. We re-enable and run foreign_key_check
# after, failing the migration loudly if the rebuild left any
# dangling reference.
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.executescript("BEGIN; " + sql + "; COMMIT;")
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
raise RuntimeError(
f"migration {version} left foreign-key violations: "
f"{[tuple(v) for v in violations]}"
)
finally:
conn.execute("PRAGMA foreign_keys = ON")
else:
conn.executescript("BEGIN; " + sql + "; COMMIT;")
conn.execute("INSERT INTO schema_migrations (version) VALUES (?)", (version,))
finally:
conn.close()
+1 -1
View File
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
db.conn().execute(
"""
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
ON CONFLICT(user_id, rfc_slug) DO NOTHING
ON CONFLICT(project_id, user_id, rfc_slug) DO NOTHING
""",
(user_id, slug),
)
@@ -0,0 +1,276 @@
-- migrate:no-foreign-keys
--
-- §22.13 / §22.4 — fold project_id into the slug-keyed PRIMARY KEY / UNIQUE
-- constraints that migration 026 deliberately left global, so a *second*
-- project can hold an entry with the same slug as the first. M1 (026) added
-- project_id additively (no rebuild); this is the rebuild that activates
-- project #2, enumerated in 026's header.
--
-- SQLite can't ALTER a PK/UNIQUE in place, so each table is rebuilt by the
-- official procedure: create `<t>__new` with the new constraint, copy, DROP
-- the live table, rename `<t>__new` -> `<t>`, recreate its indexes. FK
-- enforcement is OFF for the whole file (the `migrate:no-foreign-keys` marker
-- above tells the runner to toggle it and run foreign_key_check after).
--
-- DROP-the-live-table (rather than rename-live-to-__old) is deliberate: SQLite
-- rewrites child FK references when you RENAME a *referenced* table, so we drop
-- the old cached_rfcs (allowed with FK off) and rename the temp in. cached_rfcs
-- is rebuilt FIRST so rfc_collaborators / contribution_requests can re-point
-- their FK at its new composite (project_id, slug) key.
-- ── cached_rfcs: PRIMARY KEY (slug) -> (project_id, slug) ──────────────────
CREATE TABLE cached_rfcs__new (
slug TEXT NOT NULL,
title TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')),
rfc_id TEXT,
repo TEXT,
proposed_by TEXT,
proposed_at TEXT,
graduated_at TEXT,
graduated_by TEXT,
owners_json TEXT NOT NULL DEFAULT '[]',
arbiters_json TEXT NOT NULL DEFAULT '[]',
tags_json TEXT NOT NULL DEFAULT '[]',
body TEXT,
body_sha TEXT,
last_main_commit_at TEXT,
last_entry_commit_at TEXT,
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
models_json TEXT,
funder_login TEXT,
proposed_use_case TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
unreviewed INTEGER NOT NULL DEFAULT 0,
reviewed_at TEXT,
reviewed_by TEXT,
PRIMARY KEY (project_id, slug)
);
INSERT INTO cached_rfcs__new SELECT * FROM cached_rfcs;
DROP TABLE cached_rfcs;
ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs;
CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state);
CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs (
COALESCE(last_main_commit_at, last_entry_commit_at) DESC
);
CREATE INDEX idx_cached_rfcs_project ON cached_rfcs(project_id);
-- ── rfc_invitations: FK rfc_slug -> cached_rfcs(slug) becomes composite ────
-- (no key change of its own, but its single-column FK to cached_rfcs is now a
-- mismatch against the composite PK, so it must be rebuilt too). Rebuilt after
-- cached_rfcs (its FK target) and before the two tables that FK rfc_invitations.
CREATE TABLE rfc_invitations__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitee_email TEXT NOT NULL,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')),
token TEXT NOT NULL,
expires_at TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
accepted_at TEXT,
accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_invitations__new SELECT * FROM rfc_invitations;
DROP TABLE rfc_invitations;
ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations;
CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token);
CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status);
CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status);
-- ── cached_branches: UNIQUE (rfc_slug, branch_name) -> +project_id ─────────
CREATE TABLE cached_branches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
head_sha TEXT,
state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')),
pinned INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
last_commit_at TEXT,
closed_at TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name)
);
INSERT INTO cached_branches__new SELECT * FROM cached_branches;
DROP TABLE cached_branches;
ALTER TABLE cached_branches__new RENAME TO cached_branches;
CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state);
-- ── branch_visibility: UNIQUE (rfc_slug, branch_name) -> +project_id ───────
CREATE TABLE branch_visibility__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
read_public INTEGER NOT NULL DEFAULT 1,
contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name)
);
INSERT INTO branch_visibility__new SELECT * FROM branch_visibility;
DROP TABLE branch_visibility;
ALTER TABLE branch_visibility__new RENAME TO branch_visibility;
-- ── branch_contribute_grants: UNIQUE (rfc_slug, branch_name, grantee) -> +pid
CREATE TABLE branch_contribute_grants__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, rfc_slug, branch_name, grantee_user_id)
);
INSERT INTO branch_contribute_grants__new SELECT * FROM branch_contribute_grants;
DROP TABLE branch_contribute_grants;
ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants;
CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name);
CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id);
-- ── stars: UNIQUE (user_id, rfc_slug) -> +project_id ───────────────────────
CREATE TABLE stars__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
starred_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug)
);
INSERT INTO stars__new SELECT * FROM stars;
DROP TABLE stars;
ALTER TABLE stars__new RENAME TO stars;
CREATE INDEX idx_stars_user ON stars (user_id);
CREATE INDEX idx_stars_rfc ON stars (rfc_slug);
-- ── watches: UNIQUE (user_id, rfc_slug) -> +project_id ─────────────────────
CREATE TABLE watches__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')),
set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')),
set_at TEXT NOT NULL DEFAULT (datetime('now')),
last_participation_at TEXT,
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug)
);
INSERT INTO watches__new SELECT * FROM watches;
DROP TABLE watches;
ALTER TABLE watches__new RENAME TO watches;
CREATE INDEX idx_watches_user ON watches (user_id);
CREATE INDEX idx_watches_rfc ON watches (rfc_slug);
CREATE INDEX idx_watches_decay ON watches (state, last_participation_at);
-- ── pr_seen: UNIQUE (user_id, rfc_slug, pr_number) -> +project_id ──────────
CREATE TABLE pr_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
last_seen_commit_sha TEXT,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug, pr_number)
);
INSERT INTO pr_seen__new SELECT * FROM pr_seen;
DROP TABLE pr_seen;
ALTER TABLE pr_seen__new RENAME TO pr_seen;
-- ── branch_chat_seen: UNIQUE (user_id, rfc_slug, branch_name) -> +project_id
CREATE TABLE branch_chat_seen__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
rfc_slug TEXT NOT NULL,
branch_name TEXT NOT NULL,
last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL,
seen_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, user_id, rfc_slug, branch_name)
);
INSERT INTO branch_chat_seen__new SELECT * FROM branch_chat_seen;
DROP TABLE branch_chat_seen;
ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen;
-- ── funder_consents: PRIMARY KEY (user_id, rfc_slug) -> +project_id ────────
CREATE TABLE funder_consents__new (
user_id INTEGER NOT NULL,
rfc_slug TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
PRIMARY KEY (project_id, user_id, rfc_slug),
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE
);
INSERT INTO funder_consents__new SELECT * FROM funder_consents;
DROP TABLE funder_consents;
ALTER TABLE funder_consents__new RENAME TO funder_consents;
CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug);
-- ── rfc_collaborators: UNIQUE idx (rfc_slug, user_id) -> +project_id;
-- FK rfc_slug -> cached_rfcs(slug) becomes composite (project_id, rfc_slug)
CREATE TABLE rfc_collaborators__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')),
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO rfc_collaborators__new SELECT * FROM rfc_collaborators;
DROP TABLE rfc_collaborators;
ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators;
CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (project_id, rfc_slug, user_id);
CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id);
-- ── contribution_requests: UNIQUE idx (rfc_slug, requester) WHERE pending
-- -> +project_id; FK rfc_slug -> cached_rfcs(slug) becomes composite.
CREATE TABLE contribution_requests__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
rfc_slug TEXT NOT NULL,
requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
matched_term TEXT NOT NULL,
who_i_am TEXT NOT NULL,
why TEXT NOT NULL,
use_case TEXT,
status TEXT NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'accepted', 'declined')),
created_at TEXT NOT NULL DEFAULT (datetime('now')),
decided_at TEXT,
decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL,
invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL,
notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL,
project_id TEXT NOT NULL DEFAULT 'default',
FOREIGN KEY (project_id, rfc_slug) REFERENCES cached_rfcs(project_id, slug) ON DELETE CASCADE
);
INSERT INTO contribution_requests__new SELECT * FROM contribution_requests;
DROP TABLE contribution_requests;
ALTER TABLE contribution_requests__new RENAME TO contribution_requests;
CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status);
CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status);
CREATE UNIQUE INDEX idx_contribution_requests_one_open
ON contribution_requests(project_id, rfc_slug, requester_user_id)
WHERE status = 'pending';
-- ── proposed_use_cases: UNIQUE (scope, pr_number) -> +project_id ───────────
CREATE TABLE proposed_use_cases__new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')),
rfc_slug TEXT NOT NULL,
pr_number INTEGER NOT NULL,
use_case TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
project_id TEXT NOT NULL DEFAULT 'default',
UNIQUE (project_id, scope, pr_number)
);
INSERT INTO proposed_use_cases__new SELECT * FROM proposed_use_cases;
DROP TABLE proposed_use_cases;
ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases;
CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number);
CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug);
@@ -0,0 +1,95 @@
"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates
project #2. Proves two projects can hold the same slug, that (project_id, slug)
is still unique within a project, that the rebuilt FK is composite + enforced,
and that the no-foreign-keys migration runner left no dangling references."""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
import pytest
from app import db
class _Cfg:
def __init__(self, path):
self.database_path = path
def _fresh_db():
d = tempfile.mkdtemp()
path = Path(d) / "t.db"
db.run_migrations(_Cfg(str(path)))
return db.connect(str(path))
def _seed_two_projects(conn):
for pid in ("default", "ecomm"):
conn.execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, 'document', ?, 'public', 'super-draft')",
(pid, pid.title(), pid + "-content"),
)
def test_same_slug_coexists_across_projects():
conn = _fresh_db()
_seed_two_projects(conn)
for pid in ("default", "ecomm"):
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', ?)",
(pid,),
)
rows = conn.execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id"
).fetchall()
assert [r["project_id"] for r in rows] == ["default", "ecomm"]
def test_slug_still_unique_within_a_project():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', 'default')"
)
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Dup', 'active', 'default')"
)
def test_rfc_collaborators_composite_fk_enforced():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', 'ecomm')"
)
# Matching (project_id, slug) — FK holds.
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
"VALUES ('intro', 1, 'contributor', 'ecomm')"
)
# Same slug but a project with no such entry — composite FK must reject.
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
"VALUES ('intro', 1, 'contributor', 'default')"
)
def test_stars_unique_now_scoped_by_project():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
# Same (user, slug) under two projects coexist; a duplicate within one rejects.
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
@@ -0,0 +1,67 @@
"""§22.4 (Plan B) — per-project RFC serving. A second project's corpus renders
under its own slug namespace via /api/projects/{pid}/rfcs[/{slug}], isolated
from the default project and gated by §22.5 visibility."""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _add_project(pid, name, vis="public"):
from app import db
db.conn().execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, 'document', ?, ?, 'super-draft')",
(pid, name, pid + "-content", vis),
)
def _add_rfc(slug, title, pid, state="active"):
from app import db
db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES (?, ?, ?, ?)",
(slug, title, state, pid),
)
def test_catalog_scoped_to_one_project(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("ecomm", "Ecomm")
_add_rfc("intro", "Default Intro", "default")
_add_rfc("intro", "Ecomm Intro", "ecomm")
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
d = client.get("/api/projects/default/rfcs").json()["items"]
e = client.get("/api/projects/ecomm/rfcs").json()["items"]
d_slugs = {i["slug"] for i in d}
e_slugs = {i["slug"] for i in e}
assert "intro" in d_slugs and "only-ecomm" not in d_slugs
assert {"intro", "only-ecomm"} <= e_slugs
def test_entry_is_isolated_by_project(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("ecomm", "Ecomm")
_add_rfc("intro", "Default Intro", "default")
_add_rfc("intro", "Ecomm Intro", "ecomm")
_add_rfc("only-ecomm", "Ecomm Only", "ecomm")
assert client.get("/api/projects/default/rfcs/intro").json()["title"] == "Default Intro"
assert client.get("/api/projects/ecomm/rfcs/intro").json()["title"] == "Ecomm Intro"
# a slug that exists only in ecomm 404s under default
assert client.get("/api/projects/ecomm/rfcs/only-ecomm").status_code == 200
assert client.get("/api/projects/default/rfcs/only-ecomm").status_code == 404
def test_gated_project_catalog_404s_for_anon(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_add_project("secret", "Secret", vis="gated")
_add_rfc("hush", "Hush", "secret")
assert client.get("/api/projects/secret/rfcs").status_code == 404
assert client.get("/api/projects/secret/rfcs/hush").status_code == 404
@@ -0,0 +1,194 @@
# 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.
+2 -2
View File
@@ -1,12 +1,12 @@
{
"name": "rfc-app-frontend",
"version": "0.34.0",
"version": "0.36.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "rfc-app-frontend",
"version": "0.34.0",
"version": "0.36.0",
"dependencies": {
"@amplitude/unified": "^1.1.9",
"@codemirror/commands": "^6.10.3",
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.35.0",
"version": "0.37.0",
"type": "module",
"scripts": {
"dev": "vite",
+12 -4
View File
@@ -182,12 +182,20 @@ export async function getProject(projectId) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}`))
}
export async function listRFCs() {
return jsonOrThrow(await fetch('/api/rfcs'))
// §22.4 (Plan B): per-project serving. Given a projectId, read the
// project-scoped routes so a non-default project's corpus renders; without
// one, fall back to the default-project compat path.
export async function listRFCs(projectId) {
const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs'
return jsonOrThrow(await fetch(url))
}
export async function getRFC(slug) {
return jsonOrThrow(await fetch(`/api/rfcs/${slug}`))
export async function getRFC(projectId, slug) {
// Back-compat: getRFC(slug) (one arg) still hits the unscoped default path.
if (slug === undefined) {
return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`))
}
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
}
export async function listProposals() {
+2 -2
View File
@@ -34,9 +34,9 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
const pid = useProjectId()
useEffect(() => {
listRFCs().then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
}, [version])
}, [version, pid])
const filtered = useMemo(() => {
const needle = search.trim().toLowerCase()
@@ -1,18 +0,0 @@
// §22.10 (M3) the guard placeholder. A project that exists in the registry
// but whose corpus the backend does not yet serve (everything except the
// corpus-served default, until Plan B lands per-project RFC serving) renders
// this instead of mislabeled default-project content.
export default function NotServedPlaceholder({ project }) {
const name = project?.name || 'This project'
return (
<main className="chrome-pane">
<div className="welcome">
<h1>{name}</h1>
<p>
This project is registered, but its content isn&apos;t being served
here yet. Per-project content arrives in a later release.
</p>
</div>
</main>
)
}
+4 -6
View File
@@ -16,8 +16,6 @@ import { createContext, useContext, useEffect, useState } from 'react'
import { useParams } from 'react-router-dom'
import { getProject } from '../api'
import { brandTitle } from '../lib/brand'
import { useDeployment } from '../context/DeploymentProvider'
import NotServedPlaceholder from './NotServedPlaceholder.jsx'
const ProjectContext = createContext(null)
@@ -42,7 +40,6 @@ const THEME_TOKENS = {
export default function ProjectLayout({ children }) {
const { projectId } = useParams()
const deployment = useDeployment()
const [project, setProject] = useState(null)
const [status, setStatus] = useState('loading') // loading | ready | notfound
@@ -90,10 +87,11 @@ export default function ProjectLayout({ children }) {
)
}
const served = projectId === deployment.defaultProjectId
// §22.4 (Plan B): every registry project now serves its own corpus, so
// there is no "not served" guard a readable project renders its children.
return (
<ProjectContext.Provider value={{ project, projectId, served, noun: entryNoun(project?.type) }}>
{served ? children : <NotServedPlaceholder project={project} />}
<ProjectContext.Provider value={{ project, projectId, noun: entryNoun(project?.type) }}>
{children}
</ProjectContext.Provider>
)
}
@@ -43,11 +43,10 @@ describe('ProjectLayout', () => {
expect(document.documentElement.style.getPropertyValue('--c-accent')).toBe('')
})
it('shows the not-served placeholder for a non-default (Plan-B) project', async () => {
it('renders the corpus for a non-default project too (Plan B: every project serves)', async () => {
getProject.mockResolvedValue({ id: 'other', name: 'Ecomm', type: 'bdd', theme: {} })
renderAt('/p/other/')
expect(await screen.findByText(/isn.t being served here yet/i)).toBeInTheDocument()
expect(screen.queryByTestId('corpus')).not.toBeInTheDocument()
expect(await screen.findByTestId('corpus')).toBeInTheDocument()
})
it('shows a not-found message when the project 404s', async () => {
+4 -4
View File
@@ -125,7 +125,7 @@ export default function RFCView({ viewer }) {
const [drawerOpen, setDrawerOpen] = useState(false)
useEffect(() => {
getRFC(slug).then(entry => {
getRFC(pid, slug).then(entry => {
setEntry(entry)
// v0.15.0 analytics: fire RFC Viewed once per slug load.
// We key on the slug param rather than the loaded entry so a
@@ -140,7 +140,7 @@ export default function RFCView({ viewer }) {
setSelectedModel(def || models?.[0]?.id || '')
})
.catch(() => {})
}, [slug])
}, [slug, pid])
// Per §9.4 / §17's routing-collapse rule, super-drafts render through
// the same surface as active RFCs the bot, the chat, the change
@@ -179,7 +179,7 @@ export default function RFCView({ viewer }) {
setActionError(null)
try {
const res = await unretireRFC(slug)
getRFC(slug).then(setEntry).catch(() => {})
getRFC(pid, slug).then(setEntry).catch(() => {})
if (res?.state) navigate(entryPath(pid, slug))
} catch (err) {
setActionError(err.message)
@@ -957,7 +957,7 @@ export default function RFCView({ viewer }) {
onCompleted={() => {
setShowGraduateDialog(false)
// The catalog row and the RFC view now reflect `active`.
getRFC(slug).then(setEntry).catch(() => {})
getRFC(pid, slug).then(setEntry).catch(() => {})
getRFCMain(slug).then(setMainView).catch(() => {})
}}
/>