Merge feat/multi-project (§22 M1+M2) into main

Integrates the multi-project work so far — the §22 design drafts, M1 (the
schema spine), and M2 (project-scoped authorization + the §22.7 resolver) —
on top of the v0.32.0 retire/§13 changes that landed on main meanwhile.

Integration decisions:
- Renumbered the M1 projects migration 025_projects.sql -> 026_projects.sql.
  v0.32.0 shipped 025_retired_state.sql, which rebuilds cached_rfcs and
  predates the project_id column; running projects *after* the retire rebuild
  is required so project_id survives on a fresh database (the runner applies
  *.sql in filename order). Comment/doc references bumped to match.
- Resolved the get_rfc conflict in api.py by composing both gates: compute the
  viewer once, apply the §22.5 visibility gate, then v0.32.0's §13.7
  retired-entry owner-only check.
- api_graduation.py / api_discussion.py auto-merged cleanly (M2's threaded
  viewer/visibility gate coexists with the new retire endpoints + retired
  state).

Full suite 401 passed on a fresh DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-03 04:56:39 -07:00
18 changed files with 1967 additions and 114 deletions
+14 -11
View File
@@ -84,7 +84,7 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/discussion/threads")
async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# Ensure the default whole-doc discussion thread exists. We mint
# it on first read regardless of viewer (anonymous viewers can
# trigger the creation — the row's `created_by` is null in that
@@ -115,7 +115,7 @@ def make_router() -> APIRouter:
slug: str, body: DiscussionThreadCreateBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# v0.16.0 (roadmap item #12): the per-RFC discussion is now a
# gated surface. The platform-level `require_contributor` above
# ensures the user is signed in + admin-granted; this layer
@@ -155,8 +155,8 @@ def make_router() -> APIRouter:
async def get_discussion_thread_messages(
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
_viewer = auth.current_user(request)
_require_rfc_readable(slug)
viewer = auth.current_user(request)
_require_rfc_readable(slug, viewer)
thread = _require_discussion_thread(slug, thread_id)
rows = db.conn().execute(
"""
@@ -193,7 +193,7 @@ def make_router() -> APIRouter:
slug: str, thread_id: int, body: DiscussionMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_readable(slug)
_require_rfc_readable(slug, viewer)
# v0.16.0 (item #12): same per-RFC gate as create_discussion_thread.
if not auth.can_discuss_rfc(viewer, slug):
raise HTTPException(
@@ -218,7 +218,7 @@ def make_router() -> APIRouter:
slug: str, thread_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_readable(slug)
rfc = _require_rfc_readable(slug, viewer)
thread = _require_discussion_thread(slug, thread_id)
if not _can_resolve(rfc, thread, viewer):
raise HTTPException(
@@ -245,15 +245,18 @@ def make_router() -> APIRouter:
# ---------------------------------------------------------------------------
def _require_rfc_readable(slug: str):
"""Per the v0.3.0 anonymous-read contract: any cached RFC is readable
by anyone. Withdrawn entries refuse reads of every shape — same rule
`_require_rfc_with_repo` in `api_branches.py` follows."""
def _require_rfc_readable(slug: str, viewer):
"""Per the v0.3.0 anonymous-read contract: any cached RFC in a *readable*
project is readable by anyone. The §22.5 visibility gate is subtractive on
top (§22.7): a gated project's entries 404 to non-members. Withdrawn
entries refuse reads of every shape — same rule `_require_rfc_with_repo`
in `api_branches.py` follows."""
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
auth.require_project_readable(viewer, row["project_id"])
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
# §13.7: a retired entry is soft-deleted — refuse reads of every shape
@@ -309,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int:
def _can_resolve(rfc, thread, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_project_superuser(viewer, rfc["project_id"]):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")