§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>
This commit is contained in:
Ben Stull
2026-06-04 06:45:42 -07:00
parent 569066ef48
commit 9f548a340d
13 changed files with 249 additions and 60 deletions
+39
View File
@@ -23,6 +23,45 @@ 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
+1 -1
View File
@@ -1 +1 @@
0.36.0
0.37.0
+82
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
# ---------------------------------------------------------------
+33 -19
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,9 +105,9 @@ 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'))
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(project_id, slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
@@ -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,
),
)
@@ -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
+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.36.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(() => {})
}}
/>