§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
+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(() => {})
}}
/>