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
+9
View File
@@ -14,6 +14,15 @@ GITEA_BOT_TOKEN=
GITEA_ORG=wiggleverse
META_REPO=meta
# --- Multi-project registry (§22) ---
# The registry repo (under GITEA_ORG) the framework reads to learn which
# projects this deployment hosts. Optional through Slice M1: the registry
# mirror lands in M3, and until then the single default project's content
# repo is taken from META_REPO above. The repo's name is the deployment's
# choice. When the mirror lands, REGISTRY_REPO becomes required and
# supersedes META_REPO.
# REGISTRY_REPO=wiggleverse-registry
# --- OAuth (Gitea) ---
# In Gitea: Site Administration → Applications → Add OAuth2 Application.
# Redirect URI: {APP_URL}/auth/callback
+40 -13
View File
@@ -617,15 +617,23 @@ def make_router(
"""
viewer = auth.current_user(request)
viewer_id = viewer.user_id if viewer else None
# §22.5: a gated project's entries never surface in a non-member's
# catalog. For the single public default project this is the full set.
visible = auth.visible_project_ids(viewer)
if not visible:
return {"items": []}
placeholders = ",".join("?" for _ in visible)
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 IN ({placeholders})
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
"""
""",
visible,
).fetchall()
starred = set()
@@ -663,13 +671,15 @@ def make_router(
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
# §13.7: a retired entry is removed from every browsing surface.
# The sole exception is a site owner, so the un-retire affordance
# has somewhere to live; everyone else gets a plain 404.
if row["state"] == "retired":
viewer = auth.current_user(request)
if viewer is None or viewer.role != "owner":
raise HTTPException(404, "Not found")
viewer = auth.current_user(request)
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
# 404 to non-members.
auth.require_project_readable(viewer, row["project_id"])
# §13.7: a retired entry is removed from every browsing surface. The
# sole exception is a site owner, so the un-retire affordance has
# somewhere to live; everyone else gets a plain 404.
if row["state"] == "retired" and (viewer is None or viewer.role != "owner"):
raise HTTPException(404, "Not found")
payload = _serialize_rfc(row)
# Roadmap #26: surface the optional propose-time use case on the
# RFC view. The idea PR closes on merge, but the canonical row in
@@ -701,14 +711,21 @@ def make_router(
return row["use_case"] if row else None
@router.get("/api/proposals")
async def list_proposals() -> dict[str, Any]:
async def list_proposals(request: Request) -> dict[str, Any]:
# §22.5: idea PRs in a gated project never surface to non-members.
visible = auth.visible_project_ids(auth.current_user(request))
if not visible:
return {"items": []}
placeholders = ",".join("?" for _ in visible)
rows = db.conn().execute(
"""
f"""
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
FROM cached_prs
WHERE pr_kind = 'idea' AND state = 'open'
AND project_id IN ({placeholders})
ORDER BY opened_at DESC
"""
""",
visible,
).fetchall()
return {
"items": [
@@ -743,6 +760,8 @@ def make_router(
).fetchone()
if row is None:
raise HTTPException(404, "Not a proposal PR")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(auth.current_user(request), row["project_id"])
# Read the proposed entry file from the head branch.
slug = row["rfc_slug"]
head = row["head_branch"]
@@ -778,6 +797,12 @@ def make_router(
@router.post("/api/rfcs/propose")
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
# §22.6/§22.7: proposing a new entry requires project-level contribute
# standing. Through M2 every entry lands in the default project; M3's
# routing carries the target project. On the public default project the
# implicit-public baseline preserves the pre-multi-project flow.
if not auth.can_contribute_in_project(user, auth.DEFAULT_PROJECT_ID):
raise HTTPException(403, "You do not have contribute access to this project")
slug = payload.slug.strip().lower()
if not entry_mod.is_valid_slug(slug):
raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes")
@@ -1002,10 +1027,12 @@ def make_router(
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
rfc = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(user, rfc["project_id"])
# §6.7: refuse consent from a user with no registered credentials
# — a consent without a universe would be inert and the surface
# should fail loudly rather than silently.
+60 -38
View File
@@ -120,7 +120,9 @@ def make_router(
# -------------------------------------------------------------------
@router.get("/api/rfcs/{slug}/models")
async def list_models_for_rfc(slug: str) -> dict[str, Any]:
async def list_models_for_rfc(slug: str, request: Request) -> dict[str, Any]:
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
_require_rfc(slug, auth.current_user(request))
resolved = models_resolver.resolve_models_for_rfc(slug, providers)
return {
"models": [
@@ -140,7 +142,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/main")
async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc(slug)
rfc = _require_rfc(slug, viewer)
if rfc["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {rfc['state']}")
@@ -289,7 +291,7 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
@@ -363,7 +365,7 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
owner, repo = _repo_for(rfc)
new_branch = (body.branch_name or "").strip()
if not new_branch:
@@ -408,7 +410,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/metadata")
async def edit_metadata(slug: str, body: MetadataEditBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
# Permission: super-draft owners/arbiters per §6.3, plus app-wide
# admins/owners per §6.1. Until claim, that collapses to admin/owner.
if not _can_edit_metadata(rfc, viewer):
@@ -481,7 +483,7 @@ def make_router(
request: Request,
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -568,7 +570,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/decline")
async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_pending_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -586,7 +588,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/reask")
async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
row = _require_change(slug, branch, change_id)
if row["kind"] != "ai":
@@ -653,7 +655,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/manual-flush")
async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
_require_can_contribute(slug, branch, viewer)
owner, repo = _repo_for(rfc, branch)
path = _file_path_for(rfc, branch)
@@ -730,7 +732,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/visibility")
async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
current = _branch_vis(slug, branch)
@@ -751,7 +753,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/grants")
async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
@@ -772,7 +774,7 @@ def make_router(
@router.delete("/api/rfcs/{slug}/branches/{branch:path}/grants/{grantee_login}")
async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
creator = _branch_creator(slug, branch)
_require_branch_owner(rfc, viewer, creator)
grantee = db.conn().execute(
@@ -792,7 +794,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads")
async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
rows = db.conn().execute(
@@ -810,7 +812,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads")
async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if body.thread_kind == "flag" and not body.label:
raise HTTPException(422, "Flag threads require a label")
cur = db.conn().execute(
@@ -840,7 +842,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages")
async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
thread = _require_thread(slug, branch, thread_id)
@@ -865,7 +867,7 @@ def make_router(
slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
_require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -886,7 +888,7 @@ def make_router(
to this (slug, branch) on or before the new cursor is marked read.
"""
viewer = auth.require_user(request)
_require_rfc_with_repo(slug)
_require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
last_seen = int(body.get("last_seen_message_id") or 0) or None
@@ -909,7 +911,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/resolve")
async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
thread = _require_thread(slug, branch, thread_id)
creator = _branch_creator(slug, branch)
if not _can_resolve_thread(rfc, thread, creator, viewer):
@@ -932,7 +934,7 @@ def make_router(
slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request
):
viewer = auth.require_contributor(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
thread = _require_thread(slug, branch, thread_id)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -1018,7 +1020,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/branches/{branch:path}")
async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_rfc_with_repo(slug)
rfc = _require_rfc_with_repo(slug, viewer)
if not _can_read_branch(slug, branch, viewer):
raise HTTPException(403, "Branch is private")
@@ -1089,31 +1091,34 @@ def make_router(
# Permission + state helpers (closures, share `config` etc.)
# ------------------------------------------------------------------
def _require_rfc(slug: str):
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
# 404 to non-members — indistinguishable from an unknown slug.
auth.require_project_readable(viewer, row["project_id"])
return row
def _require_rfc_with_repo(slug: str):
def _require_rfc_with_repo(slug: str, viewer):
"""Used by every branch-scoped endpoint. Under the meta-only
topology (§1) the meta repo is the implicit target for every
entry — super-draft and active alike — so there is no per-RFC
repo check. The name is retained for call-site stability; a
withdrawn entry is still rejected."""
row = _require_rfc(slug)
row = _require_rfc(slug, viewer)
if row["state"] == "withdrawn":
raise HTTPException(409, "RFC is withdrawn")
return row
def _require_active_rfc(slug: str):
row = _require_rfc_with_repo(slug)
def _require_active_rfc(slug: str, viewer):
row = _require_rfc_with_repo(slug, viewer)
if row["state"] != "active":
raise HTTPException(409, f"RFC is {row['state']}, not active")
return row
def _require_super_draft(slug: str):
row = _require_rfc(slug)
def _require_super_draft(slug: str, viewer):
row = _require_rfc(slug, viewer)
if row["state"] != "super-draft":
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
@@ -1259,6 +1264,12 @@ def make_router(
return row["on_behalf_of"] if row else None
def _can_read_branch(slug: str, branch: str, viewer) -> bool:
# §22.5 visibility gate first (subtractive, §22.7): in a gated project
# nothing — not even main or a read_public branch — is readable by a
# non-member.
pid = auth.project_of_rfc(slug)
if not auth.can_read_project(viewer, pid):
return False
if branch == "main":
return True
vis = _branch_vis(slug, branch)
@@ -1266,7 +1277,7 @@ def make_router(
return True
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
if auth.is_project_superuser(viewer, pid):
return True
creator = _branch_creator(slug, branch)
if creator and viewer.gitea_login == creator:
@@ -1299,7 +1310,12 @@ def make_router(
# legacy `repo:` is set (nothing, after the RFC-0001 fold-back).
if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch):
return False
if viewer.role in ("owner", "admin"):
pid = auth.project_of_rfc(slug)
# §22.5 visibility gate (subtractive): no contribute in an unreadable
# project.
if not auth.can_read_project(viewer, pid):
return False
if auth.is_project_superuser(viewer, pid):
return True
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1310,7 +1326,10 @@ def make_router(
return True
vis = _branch_vis(slug, branch)
if vis["contribute_mode"] == "any-contributor":
return True
# "any contributor" means anyone with project-level write standing
# (§22.6/§22.7) — the implicit-public baseline on a public project,
# or an explicit project_contributor/admin elsewhere.
return auth.can_contribute_in_project(viewer, pid)
if vis["contribute_mode"] == "specific":
row = db.conn().execute(
"""
@@ -1331,7 +1350,9 @@ def make_router(
raise HTTPException(403, "You do not have contribute access to this branch")
def _require_branch_owner(rfc, viewer, creator: str | None) -> None:
if viewer.role in ("owner", "admin"):
# §22.6: a project_admin is the per-RFC owner/arbiter authority lifted
# to project scope, so it (and a deployment owner/admin) clears here.
if auth.is_project_superuser(viewer, rfc["project_id"]):
return
owners = json.loads(rfc["owners_json"] or "[]")
arbiters = json.loads(rfc["arbiters_json"] or "[]")
@@ -1342,11 +1363,12 @@ def make_router(
raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings")
def _can_edit_metadata(rfc, viewer) -> bool:
"""§9.5: super-draft owners/arbiters per §6.3 plus app admins/owners.
Until §13.1's claim runs, the super-draft has no owners, so the set
collapses to app admins/owners only — sensible because admin oversight
is the only path to canonicalizing edits on an unclaimed entry."""
if viewer.role in ("owner", "admin"):
"""§9.5: super-draft owners/arbiters per §6.3 plus project_admin /
app admins/owners (§22.6). Until §13.1's claim runs, the super-draft
has no owners, so the set collapses to the superuser tier only —
sensible because admin oversight is the only path to canonicalizing
edits on an unclaimed entry."""
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 "[]")
@@ -1359,7 +1381,7 @@ def make_router(
"can_read": _can_read_branch(slug, branch, viewer),
"can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False,
"can_change_branch_settings": viewer is not None and (
viewer.role in ("owner", "admin")
auth.is_project_superuser(viewer, rfc["project_id"])
or (creator is not None and viewer.gitea_login == creator)
or viewer.gitea_login in (owners + arbiters)
),
@@ -1405,7 +1427,7 @@ def make_router(
def _can_resolve_thread(rfc, thread, creator: str | None, 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 "[]")
+14 -9
View File
@@ -55,16 +55,19 @@ class ContributionRequestBody(BaseModel):
use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX)
def _require_super_draft(slug: str):
def _require_super_draft(slug: str, viewer):
"""The contribute surface only operates on a *pending* RFC. 404 on
unknown; 409 on a state that isn't a super-draft (active RFCs use the
Part-1 link, not a contribute offer; withdrawn is closed)."""
Part-1 link, not a contribute offer; withdrawn is closed). The §22.5
visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7)."""
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
"SELECT slug, title, state, owners_json, proposed_by, project_id 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"] != "super-draft":
raise HTTPException(409, "RFC is not a pending super-draft")
return row
@@ -88,7 +91,7 @@ def _viewer_relationship(viewer, slug: str) -> str | None:
"""Why this viewer can't *request* to contribute — or None if they can.
Owners/admins already have the RFC; existing collaborators are already
in. Both get a clear 409 rather than a useless self-request."""
if auth.is_rfc_owner(viewer, slug) or viewer.role in ("owner", "admin"):
if auth.is_rfc_owner(viewer, slug) or auth.is_project_superuser(viewer, auth.project_of_rfc(slug)):
return "You already own or administer this RFC."
if auth.is_rfc_collaborator(viewer, slug):
return "You're already a collaborator on this RFC."
@@ -105,16 +108,18 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/contribution-target")
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?",
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
viewer = auth.current_user(request)
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(viewer, row["project_id"])
from . import rfc_links # local import: avoid a module import cycle
owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"])
viewer = auth.current_user(request)
eligible = True
reason: str | None = None
@@ -159,7 +164,7 @@ def make_router() -> APIRouter:
slug: str, body: ContributionRequestBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug)
_require_super_draft(slug, viewer)
reason = _viewer_relationship(viewer, slug)
if reason is not None:
@@ -214,7 +219,7 @@ def make_router() -> APIRouter:
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
@@ -282,7 +287,7 @@ def make_router() -> APIRouter:
slug: str, request_id: int, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_super_draft(slug)
_require_super_draft(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(403, "Only the RFC's owner can act on contribution requests")
+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 "[]")
+16 -7
View File
@@ -199,7 +199,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/blocking-prs")
async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
rows = db.conn().execute(
"""
SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind
@@ -218,7 +218,7 @@ def make_router(
can_merge = (
viewer is not None
and (
viewer.role in ("owner", "admin")
auth.is_project_superuser(viewer, rfc["project_id"])
or viewer.gitea_login in owners
or viewer.gitea_login in arbiters
)
@@ -258,7 +258,7 @@ def make_router(
slug: str, request: Request,
) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
del viewer # no permission gate — the dialog only shows up for
# admins/owners, but the check itself is read-only.
@@ -313,7 +313,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/graduate")
async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
# §13: only owners/arbiters of the RFC and app admins/owners may
# graduate. Until §13.1's claim runs the entry has no owners, so
# the set collapses to app admins/owners for unclaimed entries.
@@ -458,7 +458,13 @@ def make_router(
@router.post("/api/rfcs/{slug}/claim")
async def claim_ownership(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_super_draft(slug)
rfc = _require_super_draft(slug, viewer)
# §22.6/§22.7: claiming an unclaimed super-draft is a contribute action.
# On a public project this is the pre-M2 baseline (an unclaimed entry
# has no owners, so any granted contributor qualifies); on a gated
# project it requires a project_contributor/admin grant.
if not auth.can_contribute_to_rfc(viewer, slug):
raise HTTPException(403, "You do not have contribute access to this project")
existing_owners = json.loads(rfc["owners_json"] or "[]")
if viewer.gitea_login in existing_owners:
return {"ok": True, "noop": True}
@@ -567,10 +573,12 @@ def make_router(
# Helpers
# -------------------------------------------------------------------
def _require_super_draft(slug: str):
def _require_super_draft(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members.
auth.require_project_readable(viewer, row["project_id"])
if row["state"] != "super-draft":
raise HTTPException(409, f"RFC is {row['state']}, not super-draft")
return row
@@ -786,7 +794,8 @@ async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of
def _can_graduate(rfc, viewer) -> bool:
if viewer is None:
return False
if viewer.role in ("owner", "admin"):
# §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters.
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 "[]")
+9 -6
View File
@@ -126,7 +126,7 @@ def make_router() -> APIRouter:
@router.post("/api/rfcs/{slug}/invitations")
async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_rfc(slug)
rfc = _require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
@@ -151,7 +151,7 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/invitations")
async def list_invitations(slug: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
_require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
@@ -207,7 +207,7 @@ def make_router() -> APIRouter:
@router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke")
async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_rfc(slug)
_require_rfc(slug, viewer)
if not auth.can_invite_to_rfc(viewer, slug):
raise HTTPException(
403,
@@ -373,15 +373,18 @@ def make_router() -> APIRouter:
# ---------------------------------------------------------------------------
def _require_rfc(slug: str):
def _require_rfc(slug: str, viewer):
"""The invitation surface only operates on a known, non-withdrawn
RFC. We refuse 404 on unknown and 409 on withdrawn mirrors the
discussion endpoints' `_require_rfc_readable` shape."""
discussion endpoints' `_require_rfc_readable` shape. The §22.5
visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7)."""
row = db.conn().execute(
"SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,),
"SELECT slug, title, state, project_id 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")
return row
+3 -1
View File
@@ -213,9 +213,11 @@ def make_router(config: Config) -> APIRouter:
@router.post("/api/rfcs/{slug}/watch")
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
rfc = db.conn().execute("SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(viewer, rfc["project_id"])
db.conn().execute(
"""
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
+17 -14
View File
@@ -85,7 +85,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft")
async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
owner, repo = _owner_repo(rfc)
path = _file_path_for(rfc)
if not _branch_has_commits_ahead(slug, branch):
@@ -128,7 +128,7 @@ def make_router(
403,
"This RFC's owner has not invited you to contribute PRs",
)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
if branch == "main":
raise HTTPException(409, "PRs open from non-main branches")
owner, repo = _owner_repo(rfc)
@@ -207,7 +207,7 @@ def make_router(
@router.get("/api/rfcs/{slug}/prs/{pr_number}")
async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
owner, repo = _owner_repo(rfc)
path = _file_path_for(rfc)
@@ -373,7 +373,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/seen")
async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_active_rfc(slug, viewer)
_require_pr(slug, pr_number)
# Take the max of stored and incoming for both cursors so a
# stale tab firing a seen-cursor advance after a fresher tab
@@ -420,7 +420,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/review")
async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
_require_active_rfc(slug)
_require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
head_branch = pr_row["head_branch"]
cur = db.conn().execute(
@@ -447,7 +447,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/merge")
async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_merge(rfc, viewer):
raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge")
@@ -479,7 +479,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw")
async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_withdraw(rfc, pr_row, viewer):
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw")
@@ -510,7 +510,7 @@ def make_router(
slug: str, pr_number: int, body: PRDescriptionBody, request: Request
) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if not _can_edit_pr_text(rfc, pr_row, viewer):
raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit")
@@ -535,7 +535,7 @@ def make_router(
@router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch")
async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]:
viewer = auth.require_contributor(request)
rfc = _require_active_rfc(slug)
rfc = _require_active_rfc(slug, viewer)
pr_row = _require_pr(slug, pr_number)
if pr_row["state"] != "open":
raise HTTPException(409, f"PR is {pr_row['state']}, not open")
@@ -661,20 +661,23 @@ def make_router(
# Helpers (closures over config/gitea/etc.)
# ------------------------------------------------------------------
def _require_rfc(slug: str):
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
# public" yields to a gated project: non-members get 404.
auth.require_project_readable(viewer, row["project_id"])
return row
def _require_active_rfc(slug: str):
def _require_active_rfc(slug: str, viewer):
"""Used by the §10 PR-flow read and write paths. Per §17's routing-
collapse rule, a super-draft RFC also routes here its body-edit
PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API
surface is identical. Under the meta-only topology (§1) an active
RFC is meta-resident too (repo is null) that is normal, not an
error, so there is no per-RFC-repo precondition."""
row = _require_rfc(slug)
row = _require_rfc(slug, viewer)
if row["state"] not in ("active", "super-draft"):
raise HTTPException(409, f"RFC is {row['state']}")
return row
@@ -780,10 +783,10 @@ def make_router(
def _can_merge(rfc, viewer) -> bool:
"""§6.1 admin/owner OR §6.3 RFC owners/arbiters."""
"""§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters."""
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 "[]")
+202 -15
View File
@@ -19,6 +19,7 @@ from fastapi import HTTPException, Request
from . import db
from .bot import Actor
from .config import Config
from .projects import DEFAULT_PROJECT_ID
@dataclass
@@ -290,6 +291,163 @@ def require_admin(request: Request) -> SessionUser:
return user
# ===========================================================================
# §22.6 / §22.7 — project-scoped authorization (the multi-project middle tier).
#
# A deployment hosts N projects (§22). Authorization for an action on an RFC is
# the *most-permissive union* of three tiers — the actor's deployment role
# (§6.1), their project role (§22.6), and their per-RFC authority (§6.3/§12) —
# with the §22.5 visibility gate and the §6.2 write-mute *subtractive* on top
# (§22.7). The per-RFC capability helpers below (`can_discuss_rfc`,
# `can_contribute_to_rfc`, `can_invite_to_rfc`) compose all three tiers, so the
# ~20 endpoint call sites inherit multi-project behavior unchanged.
#
# Through Slice M2 the only project is the migration-seeded `default` one (the
# N=1 case, §22.13); a project is resolved from an RFC slug via
# `cached_rfcs.project_id` (unique per slug while N=1). M3's registry mirror
# lets a deployment declare a second project; these gates already hold then.
#
# OPERATOR DECISIONS (M2):
# * implicit-on-public — on a `public` project a granted deployment
# `contributor` keeps the pre-multi-project write *baseline* (propose
# freely; an owned RFC's discuss/contribute is still gated by the v0.16.0
# per-RFC invite). No project_members row is needed and no backfill runs,
# so the N=1 case stays whole. Explicit project_members rows and
# gated/unlisted visibility are where the new tier actually bites.
# * preserve curation — the implicit-public baseline does NOT override per-RFC
# owner curation; only an *explicit* project_contributor/project_admin grant
# (or a deployment owner/admin) bypasses it. So §22.7's "project_contributor
# ⊇ rfc_collaborators(contributor)" holds for explicit grants, while a plain
# granted contributor on public behaves exactly as it did before M2.
# ===========================================================================
_DEPLOYMENT_SUPERUSER_ROLES = ("owner", "admin")
def project_visibility(project_id: str) -> str:
"""The project's §22.5 visibility ('gated' | 'public' | 'unlisted'). A
missing row reads as 'gated' the safe default: an unknown project is
invisible rather than open."""
row = db.conn().execute(
"SELECT visibility FROM projects WHERE id = ?", (project_id,)
).fetchone()
if row is None:
return "gated"
return row["visibility"] or "gated"
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
"""The user's *explicit* §22.6 project_members role in this project, or
None. This is the stored row only it does not fold in the deployment tier
or the implicit-on-public baseline (those live in the helpers below)."""
if user is None:
return None
row = db.conn().execute(
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?",
(project_id, user.user_id),
).fetchone()
return row["role"] if row else None
def project_of_rfc(rfc_slug: str) -> str:
"""The project an RFC belongs to (`cached_rfcs.project_id`). Falls back to
the default project when the slug isn't cached or the column is unset — the
same N=1 default migration 026 backfills."""
row = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
).fetchone()
if row is None:
return DEFAULT_PROJECT_ID
return row["project_id"] or DEFAULT_PROJECT_ID
def is_project_superuser(user: SessionUser | None, project_id: str) -> bool:
"""Maximal authority within a project: a deployment owner/admin (superuser
in every project, §22.7) or an explicit `project_admin` (§22.6). Both
subsume the per-RFC owners/arbiters tier."""
if user is None:
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
return project_member_role(user, project_id) == "project_admin"
def can_read_project(user: SessionUser | None, project_id: str) -> bool:
"""The §22.5 visibility gate. `public`/`unlisted` are readable by anyone
(anonymous included `unlisted` is link-only but the link still reads);
`gated` is readable only by a deployment owner/admin or a granted project
member of any role. Used as the subtractive read gate (a gated project's
entries 404 to non-members)."""
vis = project_visibility(project_id)
if vis in ("public", "unlisted"):
return True
# gated — members + superusers only, subject to the §6 admission floor.
if user is None or user.permission_state != "granted":
return False
if user.role in _DEPLOYMENT_SUPERUSER_ROLES:
return True
return project_member_role(user, project_id) is not None
def require_project_readable(user: SessionUser | None, project_id: str) -> None:
"""Raise 404 when the project is not readable by this viewer (§22.5: a
gated project is invisible to non-members indistinguishable from absent,
so the shape matches an unknown slug)."""
if not can_read_project(user, project_id):
raise HTTPException(status_code=404, detail="RFC not found")
def _has_write_baseline(user: SessionUser | None, project_id: str) -> bool:
"""The implicit-on-public write baseline: a granted deployment
`contributor` on a `public` project carries the pre-multi-project
write standing (still subject to per-RFC curation). Deployment
owner/admin are handled by `is_project_superuser`; on gated/unlisted a
contributor has no baseline and needs an explicit project role."""
if user is None or user.permission_state != "granted":
return False
return user.role == "contributor" and project_visibility(project_id) == "public"
def can_contribute_in_project(user: SessionUser | None, project_id: str) -> bool:
"""May the user contribute *new* content to the project (propose an entry)
the project-level (not RFC-specific) contribute standing. The union of
the override grants (superuser / explicit project_contributor) and the
implicit-public baseline, subject to the visibility gate."""
if user is None or user.permission_state != "granted":
return False
if not can_read_project(user, project_id):
return False
if is_project_superuser(user, project_id):
return True
if project_member_role(user, project_id) == "project_contributor":
return True
return _has_write_baseline(user, project_id)
def can_discuss_in_project(user: SessionUser | None, project_id: str) -> bool:
"""May the user participate in discussion in the project at all — the
project-level discuss standing (project_viewer discussant, §22.7).
A superset of `can_contribute_in_project` (a contributor can discuss)."""
if user is None or user.permission_state != "granted":
return False
if not can_read_project(user, project_id):
return False
if is_project_superuser(user, project_id):
return True
if project_member_role(user, project_id) in ("project_viewer", "project_contributor"):
return True
return _has_write_baseline(user, project_id)
def visible_project_ids(user: SessionUser | None) -> list[str]:
"""Project ids whose entries may surface in a listing for this viewer — the
§22.5 read gate applied to the catalog/idea lists. (The directory's
`unlisted`-omission and the per-project routing are M3 concerns; for the M2
listing filter we include every project the viewer can read.)"""
rows = db.conn().execute("SELECT id FROM projects").fetchall()
return [r["id"] for r in rows if can_read_project(user, r["id"])]
# v0.16.0 (roadmap item #12): per-RFC membership helpers.
#
# These don't replace `require_contributor` — they layer on top of it for
@@ -386,18 +544,30 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
pid = project_of_rfc(rfc_slug)
# §22.5 visibility gate is subtractive (§22.7) — no capability in a project
# the viewer cannot even read.
if not can_read_project(user, pid):
return False
# §22.7 union, override grants first — these bypass per-RFC curation
# (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all).
if is_project_superuser(user, pid):
return True
if project_member_role(user, pid) in ("project_viewer", "project_contributor"):
return True
# per-RFC authority (union term).
owners = _rfc_owners_set(rfc_slug)
if not owners:
# No owner to gate the invite-list — fall through to the
# platform-granted contract. The first §13.1 claim engages
# the gate; before that, anyone platform-granted can
# contribute (mirrors the v0.5.0 / v0.6.0 contract).
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None)
if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None):
return True
# implicit-public baseline (curation preserved): a granted deployment
# contributor on a public project may discuss only while the RFC is
# unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the
# pre-multi-project v0.16.0 contract.
if not owners and _has_write_baseline(user, pid):
return True
return False
def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
@@ -419,16 +589,27 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
pid = project_of_rfc(rfc_slug)
if not can_read_project(user, pid):
return False
# §22.7 union, override grants first (project_contributor ⊇
# rfc_collaborators(contributor); project_admin / superuser ⊇ all).
if is_project_superuser(user, pid):
return True
if project_member_role(user, pid) == "project_contributor":
return True
# per-RFC authority (union term). A 'discussant' row is NOT sufficient —
# PRs are the higher-privilege surface.
owners = _rfc_owners_set(rfc_slug)
if not owners:
# Same fall-through as can_discuss_rfc: until an owner exists,
# the gate is open.
return True
if user.gitea_login in owners:
return True
return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor")
if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"):
return True
# implicit-public baseline (curation preserved): until an owner exists, a
# granted deployment contributor on a public project may contribute.
if not owners and _has_write_baseline(user, pid):
return True
return False
def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
@@ -439,7 +620,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool:
return False
if user.permission_state != "granted":
return False
if user.role in ("owner", "admin"):
pid = project_of_rfc(rfc_slug)
if not can_read_project(user, pid):
return False
# Deployment owner/admin or project_admin (§22.6) may invite; otherwise
# only the RFC's frontmatter owner. Per-RFC collaborators and the
# implicit-public baseline do not get the invite-others power.
if is_project_superuser(user, pid):
return True
return is_rfc_owner(user, rfc_slug)
+8
View File
@@ -33,6 +33,7 @@ class Config:
gitea_bot_token: str
gitea_org: str
meta_repo: str
registry_repo: str
oauth_client_id: str
oauth_client_secret: str
app_url: str
@@ -80,6 +81,13 @@ def load_config() -> Config:
gitea_bot_token=_required("GITEA_BOT_TOKEN"),
gitea_org=_required("GITEA_ORG"),
meta_repo=_optional("META_REPO", "meta"),
# §22.2: the multi-project registry repo the framework reads to learn
# which projects exist. Optional through Slice M1 — the registry
# *mirror* lands in M3; until then the default project (seeded by
# migration 026 + the §22.13 startup backfill) is the only project,
# and its content_repo comes from META_REPO. Becomes required when
# the mirror lands and META_REPO is retired.
registry_repo=_optional("REGISTRY_REPO"),
oauth_client_id=_required("OAUTH_CLIENT_ID"),
oauth_client_secret=_required("OAUTH_CLIENT_SECRET"),
app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"),
+2
View File
@@ -28,6 +28,7 @@ from . import (
invites as invites_mod,
otc,
passcode as passcode_mod,
projects,
providers as providers_mod,
ratelimit,
turnstile,
@@ -99,6 +100,7 @@ async def lifespan(app: FastAPI):
config = load_config()
db.run_migrations(config)
db.init(config)
projects.seed_default_project(config) # §22.13 step 1
gitea = Gitea(config)
bot = Bot(gitea)
reconciler = cache.Reconciler(config, gitea)
+40
View File
@@ -0,0 +1,40 @@
"""Project registry — the §22 multi-project layer.
A deployment hosts one or more projects (§22.1). Through Slice M1 the only
project is the migration-seeded `default` one (the N=1 case, §22.13); the git
registry mirror that lets a deployment declare more projects lands in M3 and
will grow this module. For now this holds just the §22.13 step-1 backfill:
completing the default project's row from deployment config, which pure-SQL
migration 026 could not read.
"""
from __future__ import annotations
from . import db
from .config import Config
DEFAULT_PROJECT_ID = "default"
def seed_default_project(config: Config) -> None:
"""Fill the default project's content_repo from config.
Migration 026 seeds `default` with content_repo NULL because SQL cannot
read the environment. This sets it from META_REPO (the pre-multi-project
single-corpus repo) the first time the configured app boots, so the row
accurately names the corpus the existing rows belong to. Idempotent: only
touches the row while content_repo is still unset, so a later registry
mirror (M3) that has populated it is never clobbered.
The display `name` is deliberately left as-is: the deployment's
user-visible name lives in the frontend build (VITE_APP_NAME) today and
moves to the registry + GET /api/deployment in M3 (§22.9); the backend has
no authoritative name to backfill from yet.
"""
db.conn().execute(
"""
UPDATE projects
SET content_repo = ?, updated_at = datetime('now')
WHERE id = ? AND content_repo IS NULL
""",
(config.meta_repo, DEFAULT_PROJECT_ID),
)
+105
View File
@@ -0,0 +1,105 @@
-- §22 (multi-project deployments) — Slice M1: the project spine.
--
-- A deployment now hosts one or more *projects*, each a corpus with its own
-- content repo, slug/RFC-NNNN namespace, catalog, roster, and branding. The
-- pre-multi-project single-corpus deployment is the N=1 case: this migration
-- generates one 'default' project and stamps every existing RFC-scoped row
-- to it, so the app keeps running exactly as before with the spine
-- underneath (see docs/design/multi-project-spec.md §22.13).
--
-- STRATEGY — additive, no table rebuilds. Every slug-bearing table gets a
-- `project_id TEXT NOT NULL DEFAULT 'default'` column. The constant default
-- means every existing INSERT in the codebase that does not yet mention
-- project_id keeps working and lands rows in the default project; no query
-- breaks because, with a single project, slugs remain globally unique. The
-- column carries no inline REFERENCES clause: SQLite's ALTER TABLE ADD
-- COLUMN forbids a FK column with a non-NULL default. project_id referential
-- integrity is therefore enforced at the app layer for now; the FK lands
-- with the table rebuilds below.
--
-- ============================================================================
-- DEFERRED to the slice that activates project #2 (M3/M4). Until a second
-- project exists these are correct as-is; the moment two projects can share a
-- slug or a Gitea PR number they become cross-project collision bugs and MUST
-- be rebuilt (SQLite needs a create-copy-drop-rename per table) to fold
-- project_id into the key, and to add the project_id FK:
-- * cached_rfcs PRIMARY KEY (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 PRIMARY KEY (user_id, rfc_slug) -> +project_id
-- * rfc_collaborators UNIQUE INDEX (rfc_slug, user_id) -> +project_id
-- * contribution_requests UNIQUE INDEX (rfc_slug, requester_user_id) WHERE pending -> +project_id
-- * proposed_use_cases UNIQUE (scope, pr_number) -> +project_id
-- (PR numbers are per-content-repo = per-project)
-- cached_prs UNIQUE (repo, pr_number) is already globally unique (repo is the
-- full 'org/repo' string, distinct per project) and needs no rebuild.
-- ============================================================================
-- The project registry cache (mirrored from the git registry by the M3
-- reconciler; rows are never written from user actions). content_repo is
-- NULL until the mirror — or the M1 startup backfill (§22.13 step 1) — sets
-- it from the deployment's configured repo.
CREATE TABLE IF NOT EXISTS projects (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
content_repo TEXT,
visibility TEXT NOT NULL DEFAULT 'gated'
CHECK (visibility IN ('gated', 'public', 'unlisted')),
config_json TEXT,
registry_sha TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
);
-- The default project. visibility='public' preserves the pre-multi-project
-- open-by-default posture (§22.5, §22.13). name is a placeholder the startup
-- backfill / registry overwrites with the deployment's display name.
INSERT OR IGNORE INTO projects (id, name, visibility)
VALUES ('default', 'default', 'public');
-- Per-(user, project) membership and the §22.6 middle-tier role. This is the
-- new tier between the §6.1 deployment role (users.role, now deployment-scope
-- only) and the §6.3 per-RFC authority.
CREATE TABLE IF NOT EXISTS project_members (
project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
role TEXT NOT NULL DEFAULT 'project_viewer'
CHECK (role IN ('project_admin', 'project_contributor', 'project_viewer')),
granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
granted_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (project_id, user_id)
);
CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id);
-- The project_id spine across every slug-bearing table. Backfills existing
-- rows to 'default' via the constant column default.
ALTER TABLE cached_rfcs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE cached_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE cached_prs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_visibility ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_contribute_grants ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE stars ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE threads ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE changes ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE pr_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE branch_chat_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE watches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE notifications ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE actions ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE pr_resolution_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE funder_consents ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE rfc_invitations ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE rfc_collaborators ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE proposed_use_cases ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
ALTER TABLE contribution_requests ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default';
-- The catalog/directory lookup the M3 surfaces will make (RFCs in a project).
-- The per-table composite-key rebuilds in the DEFERRED block above will add
-- their own (project_id, …) indexes when they land.
CREATE INDEX IF NOT EXISTS idx_cached_rfcs_project ON cached_rfcs(project_id);
@@ -0,0 +1,347 @@
"""Slice M2 — project-scoped authorization + the §22.7 resolver.
M1 laid the schema spine (the `projects` / `project_members` tables and the
`project_id` column on every slug-bearing table). M2 builds the resolver on top:
the most-permissive union of the deployment role (§6.1), the project role
(§22.6), and the per-RFC authority (§6.3/§12), with the §22.5 visibility gate
subtractive on top (§22.7).
Two operator decisions are pinned here as executable expectations:
* implicit-on-public a granted deployment `contributor` keeps its
pre-multi-project write *baseline* on a `public` project (propose freely;
an owned RFC's discuss/contribute still needs the per-RFC invite) with no
project_members row. So the single public default project behaves exactly
as it did before M2 (verified across the rest of the suite, and the
`*_public_*` tests below).
* preserve curation the implicit-public baseline does NOT override per-RFC
owner curation; only an *explicit* project_contributor/admin grant (or a
deployment owner/admin) bypasses it.
Everything is exercised on the single `default` project by flipping its
visibility and granting/revoking `project_members` roles the M2 slice is
verifiable without a second project (which arrives with M3's registry mirror).
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401 — fixtures land via import
app_with_fake_gitea,
provision_user_row,
sign_in_as,
tmp_env,
)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _su(user_id: int, login: str, role: str, *, state: str = "granted"):
"""A SessionUser handle for direct resolver calls (the endpoint tests use
sign_in_as instead)."""
from app import auth
return auth.SessionUser(
user_id=user_id,
gitea_id=user_id,
gitea_login=login,
display_name=login.capitalize(),
email=f"{login}@test",
avatar_url="",
role=role,
permission_state=state,
)
def _set_visibility(project_id: str, visibility: str) -> None:
from app import db
db.conn().execute(
"UPDATE projects SET visibility = ? WHERE id = ?", (visibility, project_id)
)
def _add_member(project_id: str, user_id: int, role: str) -> None:
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)",
(project_id, user_id, role),
)
def _remove_member(project_id: str, user_id: int) -> None:
from app import db
db.conn().execute(
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
(project_id, user_id),
)
def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None:
"""A minimal cached_rfcs row — enough for the authz gates (state, owners,
project_id). project_id defaults to 'default' via migration 026 but we set
it explicitly for clarity."""
import json
from app import db
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, project_id)
VALUES (?, ?, ?, ?, '[]', '[]', ?)
""",
(slug, slug.capitalize(), state, json.dumps(owners or []), project_id),
)
# ---------------------------------------------------------------------------
# 1. The §22.7 resolver — tier composition
# ---------------------------------------------------------------------------
def test_resolver_public_project_tiers(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="ben", role="owner")
contributor = _su(1, "alice", "contributor")
owner = _su(2, "ben", "owner")
# default is public — read open to everyone incl. anonymous.
assert auth.can_read_project(None, "default") is True
assert auth.can_read_project(contributor, "default") is True
# implicit-on-public: a granted deployment contributor carries the
# write baseline without a project_members row.
assert auth.can_contribute_in_project(contributor, "default") is True
assert auth.can_discuss_in_project(contributor, "default") is True
# ... but it is NOT project_admin (curation/override authority).
assert auth.is_project_superuser(contributor, "default") is False
# a deployment owner/admin is a superuser in every project.
assert auth.is_project_superuser(owner, "default") is True
# a pending contributor has no write standing (the §6 admission floor).
pending = _su(1, "alice", "contributor", state="pending")
assert auth.can_contribute_in_project(pending, "default") is False
def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="ben", role="owner")
_set_visibility("default", "gated")
contributor = _su(1, "alice", "contributor")
owner = _su(2, "ben", "owner")
# gated: no membership → invisible and no standing.
assert auth.can_read_project(None, "default") is False
assert auth.can_read_project(contributor, "default") is False
assert auth.can_contribute_in_project(contributor, "default") is False
# deployment owner is a superuser regardless of membership.
assert auth.can_read_project(owner, "default") is True
assert auth.is_project_superuser(owner, "default") is True
# project_viewer → read + discuss, but not contribute.
_add_member("default", 1, "project_viewer")
assert auth.can_read_project(contributor, "default") is True
assert auth.can_discuss_in_project(contributor, "default") is True
assert auth.can_contribute_in_project(contributor, "default") is False
# project_contributor → contribute.
_add_member("default", 1, "project_contributor")
assert auth.can_contribute_in_project(contributor, "default") is True
assert auth.is_project_superuser(contributor, "default") is False
# project_admin → superuser within the project.
_add_member("default", 1, "project_admin")
assert auth.is_project_superuser(contributor, "default") is True
# ---------------------------------------------------------------------------
# 2. Public default unchanged — the regression floor (curation preserved)
# ---------------------------------------------------------------------------
def test_public_per_rfc_curation_preserved(app_with_fake_gitea):
"""On the public default project a granted contributor still cannot
discuss an *owned* RFC without a per-RFC invite (the v0.16.0 contract);
but an unclaimed (no-owners) entry stays open. This is the implicit-public
baseline with curation preserved."""
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
bob = _su(2, "bob", "contributor")
_seed_rfc("owned", state="active", owners=["alice"])
assert auth.can_discuss_rfc(bob, "owned") is False
assert auth.can_contribute_to_rfc(bob, "owned") is False
_seed_rfc("draft", state="super-draft", owners=[])
assert auth.can_discuss_rfc(bob, "draft") is True
assert auth.can_contribute_to_rfc(bob, "draft") is True
def test_public_read_open_write_gated_endpoints(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="bob", role="contributor")
_seed_rfc("ohm", state="active", owners=["alice"])
# anonymous can read the entry on a public project.
r = client.get("/api/rfcs/ohm")
assert r.status_code == 200, r.text
# anonymous cannot open a discussion thread (401, the §6 write floor).
r = client.post("/api/rfcs/ohm/discussion/threads", json={"message": "hi"})
assert r.status_code == 401
# ---------------------------------------------------------------------------
# 3. The §22.5 visibility gate — 404 to non-members on read
# ---------------------------------------------------------------------------
def test_gated_project_404s_non_members(app_with_fake_gitea):
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=1, login="alice", role="contributor")
provision_user_row(user_id=2, login="bob", role="contributor")
provision_user_row(user_id=3, login="ben", role="owner")
_seed_rfc("sekret", state="active", owners=["alice"])
_set_visibility("default", "gated")
# anonymous → 404 (indistinguishable from an unknown slug).
assert client.get("/api/rfcs/sekret").status_code == 404
# signed-in non-member → 404 on the entry and its discussion.
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
assert client.get("/api/rfcs/sekret").status_code == 404
assert client.get("/api/rfcs/sekret/discussion/threads").status_code == 404
# the gated entry never surfaces in the non-member's catalog.
assert client.get("/api/rfcs").json()["items"] == []
# a project_viewer member can read again.
_add_member("default", 2, "project_viewer")
assert client.get("/api/rfcs/sekret").status_code == 200
assert [i["slug"] for i in client.get("/api/rfcs").json()["items"]] == ["sekret"]
# a deployment owner can always read.
sign_in_as(client, user_id=3, gitea_login="ben", display_name="Ben", role="owner")
assert client.get("/api/rfcs/sekret").status_code == 200
# ---------------------------------------------------------------------------
# 4. The contribution gate on a gated project (401/403)
# ---------------------------------------------------------------------------
def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
"""Propose checks project-level contribute standing before any Gitea
work, so the gate is observable as a 403 (gated, non-member) without
seeding the success path."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="bob", role="contributor")
_set_visibility("default", "gated")
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
body = {"slug": "newidea", "title": "New Idea", "pitch": "A pitch.", "tags": []}
assert client.post("/api/rfcs/propose", json=body).status_code == 403
# grant project_contributor — the project gate now passes (the request
# proceeds past the gate; we assert only that it is no longer 403).
_add_member("default", 2, "project_contributor")
assert client.post("/api/rfcs/propose", json=body).status_code != 403
def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app) as client:
provision_user_row(user_id=2, login="bob", role="contributor")
_seed_rfc("spec", state="super-draft", owners=[])
_set_visibility("default", "gated")
bob = _su(2, "bob", "contributor")
# non-member: discussion thread create → 404 (visibility, before the
# write gate even applies).
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
# project_viewer: can discuss (200) but cannot contribute (resolver).
_add_member("default", 2, "project_viewer")
r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
assert r.status_code == 200, r.text
assert auth.can_contribute_to_rfc(bob, "spec") is False
# project_contributor: can contribute.
_add_member("default", 2, "project_contributor")
assert auth.can_contribute_to_rfc(bob, "spec") is True
# ---------------------------------------------------------------------------
# 5. Union of tiers + subtractive visibility gate
# ---------------------------------------------------------------------------
def test_per_rfc_authority_unions_then_yields_to_visibility(app_with_fake_gitea):
from app import auth
from test_propose_vertical import grant_rfc_collaborator
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=2, login="bob", role="contributor")
_seed_rfc("owned", state="active", owners=["alice"])
bob = _su(2, "bob", "contributor")
# public + per-RFC collaborator(contributor) → contribute (union term).
grant_rfc_collaborator(user_id=2, rfc_slug="owned", role_in_rfc="contributor")
assert auth.can_contribute_to_rfc(bob, "owned") is True
# flip to gated: the §22.5 gate is subtractive — the per-RFC grant no
# longer suffices without project membership.
_set_visibility("default", "gated")
assert auth.can_contribute_to_rfc(bob, "owned") is False
# restore read via project membership → the per-RFC union applies again.
_add_member("default", 2, "project_viewer")
assert auth.can_contribute_to_rfc(bob, "owned") is True
# ---------------------------------------------------------------------------
# 6. Revocation takes effect on the next call
# ---------------------------------------------------------------------------
def test_revoking_membership_revokes_access(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
with TestClient(app):
provision_user_row(user_id=2, login="bob", role="contributor")
_set_visibility("default", "gated")
bob = _su(2, "bob", "contributor")
_add_member("default", 2, "project_contributor")
assert auth.can_contribute_in_project(bob, "default") is True
_remove_member("default", 2)
assert auth.can_read_project(bob, "default") is False
assert auth.can_contribute_in_project(bob, "default") is False
@@ -0,0 +1,133 @@
"""Slice M1 — the §22 multi-project spine.
Migration 026 introduces the `projects` and `project_members` tables, seeds
the single `default` project (the N=1 case, §22.13), and threads a
`project_id` column onto every slug-bearing table, backfilled to `default`.
The §22.13 startup backfill then fills the default project's content_repo
from META_REPO. These tests prove the spine lands without disturbing the
single-project app.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea,
tmp_env,
)
# The 19 tables migration 026 threads project_id onto (docs/design/
# multi-project-spec.md §5 amendment list).
SLUG_TABLES = [
"cached_rfcs", "cached_branches", "cached_prs", "branch_visibility",
"branch_contribute_grants", "stars", "threads", "changes", "pr_seen",
"branch_chat_seen", "watches", "notifications", "actions",
"pr_resolution_branches", "funder_consents", "rfc_invitations",
"rfc_collaborators", "proposed_use_cases", "contribution_requests",
]
def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
rows = list(db.conn().execute(
"SELECT id, name, visibility, content_repo FROM projects"
))
assert len(rows) == 1
row = rows[0]
assert row["id"] == "default"
# public preserves the pre-multi-project open-by-default posture.
assert row["visibility"] == "public"
# §22.13 startup backfill set content_repo from META_REPO (tmp_env).
assert row["content_repo"] == "meta"
def test_project_id_on_every_slug_table(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
for table in SLUG_TABLES:
cols = {r["name"]: r for r in db.conn().execute(
f"PRAGMA table_info({table})"
)}
assert "project_id" in cols, f"{table} missing project_id"
col = cols["project_id"]
# NOT NULL with the constant 'default' backfill default.
assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL"
assert col["dflt_value"] == "'default'", f"{table}.project_id default"
def test_existing_row_backfills_to_default(app_with_fake_gitea):
"""A row inserted the old way (no project_id) lands in the default
project the trick that keeps every pre-multi-project INSERT working."""
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state) VALUES (?, ?, ?)",
("human", "Human", "active"),
)
got = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'human'"
).fetchone()["project_id"]
assert got == "default"
def test_project_members_table_shape(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
cols = {r["name"] for r in db.conn().execute(
"PRAGMA table_info(project_members)"
)}
assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"}
# The role CHECK rejects an unknown role.
db.conn().execute(
"INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')"
)
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) "
"VALUES ('default', 1, 'project_admin')"
)
import sqlite3
try:
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) "
"VALUES ('default', 1, 'nonsense')"
)
assert False, "CHECK should reject an unknown project role"
except sqlite3.IntegrityError:
pass
def test_seed_default_project_is_idempotent(app_with_fake_gitea):
"""Re-running the backfill never clobbers a content_repo already set —
so a later registry mirror (M3) wins over the META_REPO fallback."""
from app import db, projects
from app.config import load_config
app, _ = app_with_fake_gitea
with TestClient(app):
db.conn().execute(
"UPDATE projects SET content_repo = 'ohm-content' WHERE id = 'default'"
)
projects.seed_default_project(load_config())
got = db.conn().execute(
"SELECT content_repo FROM projects WHERE id = 'default'"
).fetchone()["content_repo"]
assert got == "ohm-content"
def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch):
from app.config import load_config
monkeypatch.setenv("REGISTRY_REPO", "wiggleverse-registry")
assert load_config().registry_repo == "wiggleverse-registry"
# Optional through M1: absent resolves to empty, not a startup failure.
monkeypatch.delenv("REGISTRY_REPO", raising=False)
assert load_config().registry_repo == ""
+583
View File
@@ -0,0 +1,583 @@
# Draft spec — §22 Multi-project deployments + amendments + slicing plan
> Status: **draft for review.** Binding voice, but not yet merged into
> `SPEC.md`. When accepted: §22 below is appended after §21; the amendment
> notes in Part B are applied in place; the slicing plan in Part C seeds a
> new `docs/DEV.md` build section. Rationale and the decisions behind this
> live in [`multi-project.md`](./multi-project.md). Target release: the next
> minor (a pre-1.0 minor carrying breaking changes with upgrade steps, §20.2).
---
# Part A — New canonical section
## 22. Projects: multiple corpora per deployment
A **deployment** hosts one or more **projects**. A project is a single
corpus: one content repository (§1) holding entries under `rfcs/`, with
its own per-project slug namespace (the slug is the identity — §22.4), a
declared **type** (§22.4a), catalog, philosophy, branding,
member roster, and model universe. The deployment is the substrate the
projects share — one Gitea org, one bot, one account system, one inbox, one
running process — and the surface a visitor first lands on.
Everything §§121 describe about *a corpus* is now *a project*. Everything
they describe about *a deployment* that is not corpus-specific — accounts,
the §6 admission gate, the §15 inbox, the §1 bot — stays at the deployment
level and is shared across projects. The numbered sections that assume a
single corpus are amended in Part B; §22 is the binding model they defer to.
> **Multi-project change (target: next minor — supersedes the original
> single-corpus model).** §1 originally said "for a deployment, this single
> repository is its content repository." A deployment now has a **registry**
> (§22.2) naming N content repositories, one per project. The single-corpus
> deployment is the **N=1 case** and continues to run after migration via a
> generated default project (§22.13); no deployment is forced to adopt more
> than one project. Where earlier sections say "the meta repo" or "the
> corpus," read "the project's content repo" and "the project's corpus."
### 22.1 The deployment ⇄ project relation
One deployment, N projects (N ≥ 1). A project belongs to exactly one
deployment and never moves between deployments. Projects within a deployment
are isolated by default (§22.5): an RFC, branch, thread, star, or watch
belongs to exactly one project, and no app surface joins across projects
except the per-account ones the deployment owns (the §15 inbox, the §6
account roster, sign-in).
### 22.2 The registry — git is still truth
Which projects exist, and their configuration, is declared in git, mirrored
into a `projects` cache table the same way content is mirrored into
`cached_rfcs` (§4). The registry is a file the bot reads — a `projects.yaml`
at the root of a dedicated **registry repo** under the deployment's Gitea
org. The framework learns the registry repo's location from a required env
var (`REGISTRY_REPO`, the multi-project successor to `META_REPO`); the repo's
*name* is the deployment's choice, not the framework's, per the
separation-of-concerns rule, and the framework fails loudly at startup if the
var is unset. Adding, reconfiguring, or archiving a project is a PR against
that file; the §4 webhook + reconciler keep the `projects` table in sync,
recording the merged `registry_sha` on each row for provenance.
The registry is a **deployment-side repo the framework reads**, in exactly
the sense `META_REPO` is today — not operator-tooling config. Where a
deployment is assembled by an external operator tool, that tool supplies the
`REGISTRY_REPO` value in the deployment's `.env` (as it supplies `META_REPO`
now) and is otherwise unaffected: project definitions live in git, edited by
PR, and the framework knows nothing about the tool that wrote the env var.
```yaml
# projects.yaml (registry repo root)
deployment:
name: Wiggleverse # deployment display name (replaces VITE_APP_NAME)
tagline: ... # deployment landing deck (§22.10)
projects:
- id: ohm # url-stable slug, unique within the deployment
name: Open Human Model
type: document # document | specification | bdd — immutable (§22.4a)
content_repo: ohm-content # repo under the deployment's Gitea org (§22.3)
visibility: gated # gated | public | unlisted (§22.5)
initial_state: super-draft # super-draft | active — landing state of a new
# entry; defaults from type (§22.4b)
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
```
Project **definition and configuration** live in the registry (git). Project
**membership** lives in the app db (§22.6) — it churns at user speed and is
app state, not document state, exactly as `rfc_collaborators` is (§5).
`projects` rows are never written from user actions; they flow from the
registry mirror only.
### 22.3 Content repositories — one per project
Each project names one content repo under the deployment's single Gitea org
(naming convention `<project-id>-content`). The §1 bot service account
operates org-wide across every content repo and the registry repo; nothing
about the bot, the §6 app-owned authorization, or the "app is the only
contribution surface" stance changes. There are no per-project Gitea orgs and
no per-project bot accounts.
### 22.4 The slug namespace is per-project; the slug is the identity
An entry's slug (§2) is unique **within its project**, not across the
deployment: `ohm` and `specs` may each have an `intro`. The slug **is** the
identity — a fully qualified reference is `(project_id, slug)`, and nothing
more. There is no type prefix and **no per-project numeric ID**: within a
project the slug alone is unambiguous, and the project context (its `/p/<id>/`
URL prefix and chrome) carries everything the old `RFC-NNNN` label used to.
This retires the per-project numbering of earlier drafts. The §13 graduation
flip still happens — it moves an entry from proposal to graduated state and
into the content repo — but it no longer allocates a number; the
`max(integer IDs)+1` allocator (§2.3, the old `api_graduation.py` path) is
removed. The displayed *noun* around a slug ("RFC", "Spec", "Feature") is a
presentation concern driven by the project's type (§22.4a), not part of the
identity.
**Legacy numbers.** Entries graduated *before* this change keep their existing
`id` (`RFC-NNNN`) in frontmatter as a **frozen, non-identity legacy label**
preserved and shown in the UI so external "RFC-0001"-style citations still
resolve, but never used for routing or lookup (the slug is). New entries are
never assigned one, and graduation does not write `id`. The field is read-only
provenance from here on.
### 22.4a Project type
Every project declares a `type` in the registry (§22.2), chosen at creation
and **immutable**: one of `document`, `specification`, or `bdd`. Type does not
change the engine — every type uses the same content repo (§22.3), the same
propose→branch→PR→discuss→graduate lifecycle (§§913), the same threads,
flags, and chat. Type selects exactly three things:
1. the **entry frontmatter schema** the project validates entries against (§2);
2. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog
labels);
3. the set of **type-specific surfaces** layered on top of the shared §7
catalog.
Type-specific behavior is implemented as a per-type module the framework
selects on `project.type`; the engine itself treats every entry as markdown +
frontmatter regardless of type. `type` is an **open set** in shape — a future
type is a new module plus a new allowed enum value, no schema rebuild — and
the three below are what is defined now. The type names and their behavior are
framework concepts (like role names), not deployment content: a deployment
picks which type each project is, but does not define or rename types.
- **`document`** — long-form normative prose (OHM: a model of principles and
definitions). Frontmatter is the §2 baseline (title, status,
owners/arbiters, tags). No type-specific surfaces. The §22.13 generated
default project is a `document` project, so the N=1 case is unchanged.
- **`specification`** — a versioned technical specification (this framework's
own `SPEC.md` is the archetype: numbered normative sections, upgrade steps).
Frontmatter adds spec metadata (`version`, lifecycle `status` of
draft/active/superseded, `supersedes`). **Type-specific surface — release
planning:** group entries/changes into versioned releases, carry a
changelog + §20-style upgrade-steps per release, and surface "what is in the
next release."
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
Given/When/Then scenarios with acceptance criteria. Frontmatter adds feature
metadata and an optional link to the `specification` entries a feature
verifies. **Type-specific surface:** a scenario/acceptance view, and — where
a deployment runs a BDD project alongside a specification project — a
coverage view mapping features to the spec sections they exercise.
> **Draft note.** The shared-engine boundary is locked; the per-type
> *schemas and surfaces* above (notably the specification release-planning
> data model and whether BDD scenarios are free-form markdown or a parsed
> Given/When/Then structure) are first proposals, to be pinned in the
> type-surface slice (Part C, M5).
### 22.4b Initial state of a new entry
A project sets the **landing state** a new entry takes when its creating
idea-PR merges (§2.4) — the `initial_state` registry field, one of the §2.4
super-draft entry-states:
- **`super-draft`** (default for `document` and `specification`) — a new entry
lands as a super-draft and must be explicitly graduated (§13) to reach
`active`. This is today's flow: propose → super-draft → review → graduate.
- **`active`** (default for `bdd`) — a new entry lands `active` on the idea-PR
merge, with the **`unreviewed` flag set** (§22.4c). The §13 graduate gate is
not surfaced — the entry is already active — but because nothing reviewed
it, the flag marks it as not-yet-vetted until an owner clears it. A behavior
spec is captured fact, not a proposal under deliberation, so it skips the
review-then-promote ceremony; branches, PRs, and discussion work afterward
exactly as on any `active` entry.
The default comes from the project's **type** (§22.4a) — each type module
supplies it — but `initial_state` is an independent registry knob: a
deployment may run a `bdd` project with `super-draft` if it wants a review
gate, or (less commonly) a `document` project that auto-actives. It changes
only the *landing state and whether graduation is required*; the underlying
propose→branch→PR→discuss engine is unchanged (shared-engine rule, §22.4a).
The §22.13 default project keeps `super-draft`, preserving the N=1 flow.
### 22.4c The `unreviewed` flag
An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
`state`. It records whether a human gate has vetted the entry:
- An entry that reaches `active` by the normal **graduate** path (§13) is
**never** flagged — the graduate action, taken by an owner/admin, *is* the
review.
- An entry that **skips straight to `active`** via `initial_state: active`
(§22.4b) lands with `unreviewed = true`, because nothing reviewed it.
A **project owner**`project_admin`, or a deployment `owner`/`admin` (§22.7)
— clears the flag with a **mark-reviewed** action (§17), the same authority
that graduates an entry. Clearing stamps `reviewed_at`/`reviewed_by` for
provenance, paralleling `graduated_at`/`graduated_by`. The flag is a property
of the entry (frontmatter, §2 amendment), so it is git-truth and survives a
cache rebuild, exactly like `state`.
The §7 catalog gains an **unreviewed filter** so an owner can find the entries
awaiting review; it is the natural worklist for the mark-reviewed action.
`unreviewed` only applies to `active` entries — a `super-draft` is pre-review
by definition, and `withdrawn` is out of scope.
### 22.5 Project visibility
Each project carries a `visibility`, defaulting to **gated**:
- **`gated`** (default) — the project is invisible to non-members. It does
not appear in the directory (§22.10), its RFCs return 404 to non-members,
and reading or writing anything in it requires membership (§22.6). This is
the baseline because a deployment may host specs and vision docs it is not
ready to publish.
- **`public`** — any visitor may read the project's RFCs under the §6.1
anonymous-read contract; the project appears in the directory; contributing
still requires a `project_contributor` grant. This is the mode that
preserves the pre-multi-project open-by-default behavior, and the mode a
generated default project (§22.13) is seeded into.
- **`unlisted`** — readable by anyone with a direct link, but not shown in
the directory and not enumerated by `GET /api/deployment`.
Visibility is the project's; it does not relax the §11 per-branch
`read_public` controls *within* a project, which continue to apply on top.
### 22.6 Project membership and roles
Membership is a `project_members(project_id, user_id, role, granted_by,
granted_at)` table, one role per (user, project). The role is a **new middle
tier** between the §6.1 deployment roles and the §6.3 per-RFC authority:
1. **`project_viewer`** — read the project's RFCs and participate in
discussion (chat, flags) on anything readable. No propose/branch/PR. The
discuss-only counterpart of the §12 per-RFC `discussant`, at project scope.
2. **`project_contributor`** — everything a viewer can do, plus the §6.1
contributor capabilities *within this project*: propose RFCs into it,
create branches, open PRs, claim unclaimed super-drafts.
3. **`project_admin`** — everything a contributor can do, plus the §6.1
admin capabilities *within this project*: manage its membership, act on
any RFC in it (merge on behalf of arbiters, graduate, set branch
visibility, withdraw/reopen), and edit per-RFC delegated authority.
`project_admin` is the §6.3 delegated-authority idea lifted from per-RFC
to per-project: an admin scoped to one corpus, not the deployment.
Membership and role are still gated by the deployment-level
`users.permission_state='granted'` (§6): a pending account has no write
capability in any project regardless of its `project_members` rows.
### 22.7 How the three tiers compose
Authorization for an action on an RFC resolves by taking the **most
permissive** of:
- the actor's **deployment role** (§6.1) — `owner`/`admin` are superusers in
every project; a plain authenticated `contributor` has, by itself, only
anonymous-equivalent access to a project until §22.6 grants it a role
(subject to §22.5 visibility);
- the actor's **project role** in that RFC's project (§22.6);
- the actor's **per-RFC authority** in that RFC (§6.3 `owners`/`arbiters`,
§12 `rfc_collaborators`).
Concretely: deployment `owner`/`admin``project_admin` ⊇ RFC
`owners`/`arbiters`; deployment `contributor` + `project_contributor` ⊇ RFC
`rfc_collaborators(contributor)`; `project_viewer``discussant`. The §6.2
write-mute and the §22.5 visibility gate are subtractive on top of whatever
the union grants.
`users.role` (§5) now means *deployment* level only. No schema change demotes
an existing owner/admin; their powers simply read as "superuser in every
project" rather than "superuser in the corpus."
### 22.8 Discovery and joining a gated project
Because a gated project is invisible to non-members, joining is by one of:
- **Invite** — a `project_admin` (or deployment admin/owner) adds a user
directly, writing a `project_members` row and fanning a §15 notification.
This reuses the §12 per-RFC invitation machinery, re-scoped to the project.
- **Request to join** — a surface analogous to §28's contribution-requests:
a user who knows a project exists (e.g. by direct link to an `unlisted`
project, or by out-of-band referral) can request membership; a
`project_admin` accepts or declines from the inbox. The request names the
desired role (defaulting to `project_viewer`).
A `public` project needs neither: read is open, and the existing §6 / §12
contribute-grant paths cover write access.
### 22.9 Branding is resolved at runtime
`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
cannot serve N projects. Deployment and project identity are served at
runtime:
- `GET /api/deployment` — the deployment `name`, `tagline`, and the list of
projects the caller can see (gated projects filtered by the caller's
membership; `unlisted` omitted).
- `GET /api/projects/:id` — that project's `name`, `tagline`, philosophy
pointer, and optional `theme` token overrides applied over the §-default
`tokens.css`.
The frontend reads these instead of `import.meta.env.VITE_APP_NAME`. Two
chrome layers result: **deployment chrome** (the directory/landing, the
project switcher, the shared inbox) and **project chrome** (the §7 catalog,
the §8 RFC view, the §14 philosophy — all per project).
### 22.10 Routing and the deployment landing
Every corpus-scoped route gains a project segment: `/p/<project>/…` carries
the §7 catalog, the §8 entry view at `/p/<project>/e/<slug>`, the §9/§10
`/p/<project>/proposals/<n>`, and the §14 `/p/<project>/philosophy`. The entry
segment is the **generic `/e/`** for every type — the displayed noun ("RFC",
"Spec", "Feature") is a type-driven label (§22.4a), not part of the path, so
routing stays a single type-agnostic path and avoids colliding with the
reserved sibling segments (`proposals`, `philosophy`, …). The root `/` is the
**deployment landing**: a directory of the projects the visitor can see (per
§22.5), plus sign-in. An anonymous or non-member visitor sees only `public`
projects there. The §8.1 breadcrumb gains a leading project segment:
`OHM / Human main` (slug, with the type-driven noun as its label).
### 22.11 Notifications span projects, one inbox
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
caller's projects. `notifications` and `watches` carry `project_id` (§5
amendment) so the inbox filters by project and a user can mute an entire
project. Quiet hours, digest cadence, and email preferences stay per-account
at the deployment level (§5, §15).
### 22.12 Per-project model universe
A project's `enabled_models` (registry, §22.2) defines its operator universe,
overriding the deployment `ENABLED_MODELS` (§18) when present and falling
back to it when absent. The §6.6 per-RFC `models:` list and the §6.7 funder
universe resolve *within* the project's universe — the resolution order
becomes funder universe ∩ §6.6 list ∩ project universe, with the project
universe substituting for the deployment universe at the outermost step.
### 22.13 Migration — the default project (the N=1 case)
A deployment upgrading from a pre-multi-project version is migrated to a
single **default project** so it keeps running unchanged:
1. The migration generates a `projects` row from current config:
`META_REPO → content_repo`, `VITE_APP_NAME → name`, `visibility = public`
(preserving the deployment's current open-by-default posture),
`type = document` (every pre-multi-project corpus is a document corpus),
and the `id` a **config-derived slug**: `DEFAULT_PROJECT_ID` if set, else a
slug of the deployment name, falling back to the literal `default`. (M1's
migration 026 seeds the bootstrap id `default`; the §C-M3 step re-stamps it
to the config-derived slug before any `/p/<id>/` route is public, so the id
is meaningful — e.g. `/p/ohm/…` — and never renamed after URLs go live. The
id stays framework-generic: the framework supplies no deployment name.)
2. Every existing RFC-scoped row (§5 amendment list) is stamped with that
`project_id`.
3. Old corpus-root URLs (`/rfc/<slug>`, `/proposals/<n>`) 308-redirect to
their `/p/<default-id>/…` equivalents — the entry view to
`/p/<default-id>/e/<slug>` (§22.10) — so existing links survive.
4. The operator creates the registry repo (§22.2) declaring the default
project; until they add a second project, the deployment is functionally
identical to before, with one extra path segment.
This is the §20.4 upgrade-steps content for the release.
---
# Part B — Amendments to existing sections
Applied in place, in the established amendment-note style (cf. §1's
"Topology change (v0.31.0)").
- **§1 Repository topology.** Add the §22 amendment note (above). "This
single repository is its content repository" → "each *project* names one
content repository; the deployment's registry (§22.2) lists them." The bot
and app-owned-authorization paragraphs are unchanged and now read
org-wide.
- **§2 Meta schema / §2.3 IDs.** Slugs are unique within a project; entry
filenames are unchanged (per content repo). §2.3's `RFC-NNNN` `max+1`
allocation is **removed** — the slug is the identity (§22.4); there is no
per-project number. Entries graduated before this change keep their `id`
as a frozen, read-only legacy display label (§22.4), never used for lookup.
The entry frontmatter schema becomes type-dependent
(§22.4a): `document` keeps today's fields, `specification` and `bdd` add
their type metadata. New `active`-entry fields: `unreviewed` (bool) and the
`reviewed_at`/`reviewed_by` provenance pair (§22.4c), paralleling
`graduated_at`/`graduated_by`.
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition now
targets the project's `initial_state` (§22.4b): `super-draft` as today, or
straight to `active` when the project (e.g. a `bdd` project) lands entries
there — in which case the entry is stamped `unreviewed` (§22.4c). A new
`active ─[mark-reviewed, owner/admin]→ active` self-transition clears the
flag. The rest of the machine is unchanged.
- **§5 Data model.** Add `project_id` to: `branch_visibility`,
`branch_contribute_grants`, `stars`, `threads`, `changes`, `watches`,
`notifications`, `rfc_invitations`, `rfc_collaborators`,
`contribution_requests`, `funder_consents`, the `*_seen` cursors, `actions`,
and the §4 cache tables `cached_rfcs` (PK → `(project_id, slug)`;
also mirrors the `unreviewed` frontmatter flag, §22.4c, so the §7 catalog
filter can query it without reading every entry file),
`cached_branches`, `cached_prs`, `pr_resolution_branches`,
`proposed_use_cases`. Add the new tables `projects` (carrying the immutable
`type`, §22.4a) and `project_members` (§22.2, §22.6). `users.role` is
annotated as deployment-scope (§22.7).
- **§6.1 Roles.** Add the §22.7 composition note: deployment roles are now
one of three tiers; a plain `contributor` has no implicit access to a
project until §22.6 grants a project role (subject to §22.5).
- **§6.3 Per-RFC delegated authority.** Note that `project_admin` (§22.6) is
the same delegation idea at project scope, sitting above per-RFC authority.
- **§7 Left pane.** The catalog is per-project, under `/p/<project>/`. The
deployment directory (§22.10) is a new surface above it. The catalog gains
an **unreviewed filter** (§22.4c) — the owner's worklist of `active` entries
that landed unreviewed.
- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10). The entry is
named by its slug, not a number (§22.4); the noun shown around it ("RFC",
"Spec", "Feature") is the project type's label (§22.4a).
- **§13.3 Graduation flip.** Operates on the project's content repo. It flips
status and moves the entry, but **allocates no number** — the slug is the
identity throughout (§22.4); the old per-project `RFC-NNNN` allocation is
gone. Graduation is also **conditional on the project's `initial_state`
(§22.4b)**: a project that lands entries `active` has no super-draft phase,
so the graduate action is a no-op there and is not surfaced. For those
entries the **mark-reviewed** action (§22.4c) takes graduation's place as
the owner/admin vetting step — it clears `unreviewed` instead of flipping
state.
- **§14.1 Pre-login landing.** Splits into deployment landing (the directory,
§22.10) and per-project philosophy/deck (§14 under `/p/<project>/`).
Deployment name comes from `GET /api/deployment`, not `VITE_APP_NAME`.
- **§17 Backend surface.** RFC routes gain the `/p/<project>` /
`project_id` scoping; add `GET /api/deployment`, `GET /api/projects/:id`
(returns the project's `type`, §22.4a), and the `project_members`
management + request-to-join endpoints (§22.6, §22.8). Add a
**mark-reviewed** endpoint clearing an entry's `unreviewed` flag (§22.4c,
owner/admin), and an `unreviewed` filter param on the catalog list. Type-
specific surfaces (§22.4a) add their own routes, mounted only for projects of
the matching type — e.g. the `specification` release-planning endpoints and
the `bdd` scenario/coverage endpoints.
- **§18 Stack.** `ENABLED_MODELS` is the deployment fallback; per-project
`enabled_models` overrides it (§22.12).
- **§20 Versioning / surface.** `VITE_APP_NAME` deprecated in favor of the
registry + `GET /api/deployment` (§22.9). New required backend env var
`REGISTRY_REPO` (the §20.3 env contract); `META_REPO` becomes legacy,
consulted only by the §22.13 migration to seed the default project's
`content_repo`, then unused. The registry file shape and the
`projects`/`project_members` schema join the §20.3 versioned surface. The
release is a pre-1.0 minor with a §22.13 upgrade-steps block. Note for the
changelog: a deployment assembled by an external operator tool upgrades
through the same pinned-version path as any other — the only deploy-surface
change is swapping the `META_REPO` overlay value for `REGISTRY_REPO`; the
framework's versioned contracts (`/api/health`, `VERSION`, the pin file)
are unchanged.
---
# Part C — Slicing plan
Seven slices carry §22 and its amendments end-to-end. The ordering mirrors
DEV.md's original principle — foundations and the cache/permission spine
first, the surfaces that consume them after, hardening last. Each slice is
shippable: a deployment can stop at any slice boundary and still run (the
default project keeps the N=1 case whole throughout). The project `type`
(§22.4a) rides M3 (config) and M5 (its surfaces); M1M4 are type-agnostic
because the engine is.
**M1 — The project spine (schema + default-project migration).** *(landed)*
The `projects` and `project_members` tables; `project_id` threaded
additively onto every slug-bearing §5 table (migration 026); the §22.13
default project generated and every existing row backfilled to it; the
startup backfill that fills the default project's `content_repo` from
`META_REPO`; `REGISTRY_REPO` wired into config (consumed in M3). No UI, no
routing change, no registry mirror yet — the app runs exactly as before,
single project, with the spine underneath. Additive only: no table rebuilds
(the slug-keyed uniqueness/PK rework is deferred to the slice that activates
project #2, enumerated in migration 026's header). This is the foundation
everything after builds on.
**M2 — Project-scoped authorization + the §22.7 resolver.** *(landed)*
The three-tier composition: `project_members` roles, the most-permissive
union with deployment role and per-RFC authority, the §22.5 visibility gate as
a 404 on read and 401/403 on write. Every §17 write endpoint surveyed in
§6.1's audit re-checked under the project axis. Still single visible project;
verifiable by granting/revoking roles on the default project and flipping its
visibility (`backend/tests/test_multi_project_authz_vertical.py`). Pure
app-layer — the resolver primitives live in `app/auth.py`
(`project_visibility`, `project_member_role`, `is_project_superuser`,
`can_read_project`, `can_contribute_in_project`, `require_project_readable`,
`visible_project_ids`), composed into the existing per-RFC capability helpers
and threaded into every RFC-resolution gate (`_require_rfc*`,
`_require_super_draft`, `_require_rfc_readable`, the branch/PR/graduation deep
gates). No migration (M1 shipped the tables) and no behavior change on the
public default project.
Two operator decisions pin how the implicit grant behaves on a `public`
project: **implicit-on-public** — a granted deployment `contributor` keeps its
pre-multi-project write baseline with no `project_members` row, so the N=1 case
stays whole (no backfill); and **preserve curation** — that implicit baseline
does *not* override per-RFC owner curation (only an explicit
project_contributor/admin or a deployment owner/admin does), so the v0.16.0
per-RFC invite contract is unchanged on public. Explicit `project_members`
rows and `gated`/`unlisted` visibility are where the new tier bites. This is a
deliberate liberalization of §22.5's literal "contributing still requires a
grant" for the public case; gated/unlisted honor the grant model exactly.
**M3 — Registry mirror + routing + runtime branding.** The §4 registry
mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects`
rows beyond the default); the **re-stamp of the default project's bootstrap
`id`** (`default` → the config-derived slug, §22.13 step 1) which must land
here, before any `/p/<id>/` URL is public; the `/p/<project>/` route prefix
with the generic `/e/<slug>` entry segment (§22.10) and the 308 redirects off
the old corpus-root URLs (`/rfc/<slug>``/p/<default-id>/e/<slug>`); `GET
/api/deployment` and `GET /api/projects/:id`; the frontend cut from `VITE_APP_NAME` to runtime config,
per-project `theme` token overlay. The deployment directory at `/` and the
project switcher in deployment chrome. This slice also adds the additive
`type` and `initial_state` columns to `projects` (a small migration — M1
shipped `projects` without them), mirrors both from the registry, returns
them on `GET /api/projects/:id`, and drives the entry-noun terminology off
`type` (§22.4a). It also teaches the shared creation path to honor
`initial_state` (§22.4b) — land a new entry `active` instead of `super-draft`,
stamping `unreviewed` and skipping the graduate gate when the project says so
— plus the `unreviewed` frontmatter fields (§22.4c) mirrored into
`cached_rfcs`, the owner/admin mark-reviewed action, and the §7 catalog
unreviewed filter that queries that cached column. But no type
*surfaces* yet; beyond their label, landing state, and review flag, all three
types still look the same here. After M3 a deployment with two registry projects is
fully navigable — which makes this the slice that must also land the deferred
slug-keyed uniqueness/PK rebuilds (migration 026 header) before a second
project can collide with the first.
**M4 — Per-project corpus surfaces (the second-project acceptance pass).** The
§7 catalog, §8 entry view, §9/§10 proposal/PR flows, §13 graduation, and §14
philosophy all confirmed working under project scope with the per-project,
slug-only namespace (§22.4). This is mostly *inherited* from M1M3 — the work
is an end-to-end pass that proves a second `document` project's full lifecycle
(propose → super-draft → graduate, identified by slug *in that project*), not
new build. Naming it explicitly as the acceptance slice keeps scope that
belongs in M3 from leaking in.
**M5 — Type modules + type-specific surfaces.** The per-type layer of §22.4a:
type-specific entry-frontmatter validation (`specification`/`bdd` metadata);
the `specification` **release-planning** surface; the `bdd` scenario/coverage
surface. Type-scoped routes mounted only for matching projects (§17). The
shared engine is untouched — this slice only adds the layers on top, so a
`document` project is unaffected and the M4 acceptance still holds. (The
per-type schema/surface details are the §22.4a draft note's open work.)
**M6 — Membership lifecycle.** §22.8 invite (re-scoped §12 machinery) and
request-to-join (re-scoped §28); the inbox surfacing of join requests; the
§22.11 cross-project inbox with `project_id` filtering and project-level
mute. The admin surface for managing a project's roster.
**M7 — Hardening + operator path.** Per-project `enabled_models` resolution
(§22.12) including funder/§6.6 intersection; the registry's place in
`docs/DEPLOYMENTS.md` and the flotilla operator tooling; end-to-end tests
spanning two projects with disjoint membership; the §20.4 changelog +
upgrade-steps block; the SPEC merge (Part A appended, Part B applied).
## Open items folded into the slices
- **Registry repo vs. file-in-existing-repo***resolved:* a dedicated
registry repo the framework reads via the `REGISTRY_REPO` env var (§22.2).
Confirmed against the flotilla operator-tooling spec: the registry is
deployment-side git content (like the corpus), not operator config, so the
operator tool's only change is swapping the `META_REPO` overlay value for
`REGISTRY_REPO`. No flotilla architectural change; no new framework⇄tool
contract. With OHM becoming one project among several, the registry sits
*above* any single project's content repo, so a file inside one project's
repo is wrong — a dedicated repo is the right home.
- **Request-to-join vs. invite-only** — drafted with both (§22.8); M6
(membership lifecycle) may ship invite-only first and add request-to-join
second if scope demands.
- **Per-type schemas and surfaces** — the §22.4a draft note's open work:
the `specification` release-planning data model and whether `bdd` scenarios
are free-form markdown or a parsed Given/When/Then structure. Pinned in M5.
+365
View File
@@ -0,0 +1,365 @@
# Design sketch — multi-project deployments
> Status: **draft / sketch.** Not binding. This precedes the SPEC edits it
> describes. Decisions captured here were made interactively; open questions
> are flagged inline. When this stabilizes it folds into `SPEC.md` (§1, §2,
> §5, §6, §7, §8, §13, §14, §17, §20) and ships as a pre-1.0 minor with
> upgrade steps.
## The reframe
Today the framework hardcodes **deployment : corpus = 1 : 1**. One deployment
is one Gitea content repo (`META_REPO`), one global slug namespace, one
`VITE_APP_NAME` baked into the build, one flat catalog. SPEC §1 says it
plainly: "this single repository is its content repository."
This change makes it **deployment : project = 1 : N**, where *today's entire
deployment becomes the N=1 case*. A **project** is what a corpus is now: a
content repo, its own slug namespace, a declared **type** (§ "Project types"
below), its own catalog, philosophy, branding, member roster, and
enabled-models universe. The **deployment** (the subdomain — e.g. Wiggleverse)
becomes a thin shell hosting a directory of projects plus a shared
account/notification layer.
OHM — a *document* project — becomes one project among several: specs
(*specification* projects), behavior suites (*BDD* projects), vision docs, …
all under one deployment.
The value of this framing: the migration stays mechanical. Everything
deployment-scoped today splits cleanly into:
- **stays deployment-scoped** — accounts, the beta/permission gate, the inbox,
the bot service account, the Gitea org;
- **becomes project-scoped** — the corpus, branding, roles, catalog, philosophy,
enabled models.
## Decisions (locked)
1. **Project registry lives in git.** A registry (a `projects.yaml` / registry
repo) declares which projects exist and their config; adding a project is a
PR. Mirrored into a `projects` cache table. Keeps the git-is-truth invariant.
2. **Projects are membership-gated by default** (private). A non-member does
not see a private project exists. `visibility` is still a per-project field
with `public` and `unlisted` escape hatches (see §3) — gated is the default,
not the only mode.
3. **Entries are identified by slug, scoped to the project.** A fully
qualified reference is `(project_id, slug)`. There is *no* type prefix and
*no* per-project numeric ID: inside a project the slug alone is
unambiguous, and the project (its URL prefix, its chrome) supplies all the
surrounding context. This **supersedes** the earlier per-project `RFC-NNNN`
allocation idea — graduation (§13) still flips an entry's status, but no
longer mints a number.
4. **Each project declares a `type`**`document`, `specification`, or `bdd`.
Type is chosen when the project is created (in the registry), is immutable,
and selects the project's entry frontmatter schema, its terminology/labels,
and any type-specific surfaces (e.g. release planning for specifications).
All types ride the *same* propose→branch→PR→graduate engine, threads,
flags, and chat; type layers on top — it does not fork the lifecycle.
## Project types
A project's `type` is a registry field, fixed at creation. It does **not**
change the engine — every type uses the same content repo, the same
propose→branch→PR→discuss→graduate lifecycle, the same threads/flags/chat. It
selects three things: the **entry frontmatter schema** the project validates
against, the **terminology** the chrome uses for an entry, and the set of
**type-specific surfaces** the project exposes on top of the shared catalog.
The engine treats every entry as markdown + frontmatter regardless of type;
type-specific behavior is a layer, implemented as a per-type module the
framework selects on `project.type`.
> These three definitions — especially the specification release-planning
> surface and the BDD scenario model — are first drafts. The schema/surface
> details below are proposals to refine, not yet locked.
- **`document`** — long-form normative prose (OHM is the archetype: a model
of principles and definitions). Frontmatter is today's entry schema
(title, status, owners/arbiters, tags). **No** type-specific surfaces; this
is the baseline, and the N=1 default project (§7) is a `document` project.
- **`specification`** — a versioned technical specification (this app's own
`SPEC.md`, with numbered normative sections and upgrade steps, is the
archetype). Frontmatter adds spec metadata (e.g. `version`, lifecycle
`status` of draft/active/superseded, `supersedes`). Type-specific surface:
**release planning** — group entries/changes into versioned releases, track
the changelog + upgrade-steps for each, and show "what's in the next
release." (This mirrors how rfc-app itself runs VERSION + CHANGELOG +
§20 upgrade steps.)
- **`bdd`** — behavior-driven feature specs: each entry describes a feature
as scenarios in Given/When/Then form with acceptance criteria. Frontmatter
adds feature metadata and an optional link to the `specification` entries a
feature verifies. Type-specific surface: a scenario/acceptance view, and
(where a deployment pairs a BDD project with a specification project) a
coverage view linking features back to the spec sections they exercise.
Types are an **open set** in shape — a new type is a new module plus a new
allowed `type` value; it needs no schema migration beyond the enum. Document,
specification, and BDD are the three defined now.
**Landing state (`initial_state`).** A project also sets what state a new
entry lands in when its idea-PR merges — `super-draft` (the normal
propose→review→graduate flow) or `active` (graduated on submission). The
default comes from the type: `document` and `specification` default to
`super-draft`; **`bdd` defaults to `active`** — a behavior spec is captured
fact, not a proposal under deliberation, so it skips the review-then-promote
gate. It's an independent registry knob, so a deployment can override the
default per project. This changes only the landing state and whether
graduation is required; the propose→branch→PR engine is unchanged.
**The `unreviewed` flag.** Skipping straight to `active` means nothing vetted
the entry, so it lands with an **`unreviewed`** flag set. An entry that reaches
`active` the normal way — super-draft → graduate — is never flagged, because
graduation *is* the review. A project owner clears the flag with a
**mark-reviewed** action (same authority as graduate), and the catalog has an
**unreviewed filter** so owners can find the entries still awaiting review. The
flag is an entry property (frontmatter, git-truth like `state`), orthogonal to
the `active` state and only meaningful on `active` entries.
The separation-of-concerns rule (CLAUDE.md) is satisfied: the *type names*
and their behavior are framework concepts (like role names), not
deployment-specific content. A deployment chooses *which* type each of its
projects is; it does not define new types or rename them.
## 1. Git topology — one content repo per project
One Gitea org for the deployment, **N content repos** (`ohm-content`,
`specs-content`, `vision-content`, …). The bot already operates org-wide; it
gains more repos and one registry repo. Slug uniqueness becomes naturally
per-repo = per-project (and the slug is the whole identity — see Decision 3).
Rejected alternatives: subdirectories in one repo (`projects/<id>/rfcs/…`)
churns every path / branch-name / graduation code path and can't carry
git-layer access control per project; prefixed slugs pollute the namespace.
### The registry repo
A small repo (or a top-level file in a deployment repo) the bot reads and
mirrors. Sketch shape:
```yaml
# projects.yaml
deployment:
name: Wiggleverse
tagline: ...
projects:
- id: ohm # url slug, stable, unique in deployment
name: Open Human Model
type: document # document | specification | bdd (immutable)
content_repo: ohm-content # repo under the deployment's Gitea org
visibility: gated # gated | public | unlisted
initial_state: super-draft # super-draft | active — landing state of a
# new entry; defaults from type
philosophy_repo_path: PHILOSOPHY.md
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
theme: { accent: "#5b5bd6" } # optional per-project token overrides
```
Project **definition/config** is in git; project **membership** is in the DB
(it churns like `rfc_collaborators` and is app-state, not document state). The
registry gets the same webhook + reconciler treatment as content repos.
> Open: does the registry get its own repo, or is it a file in an existing
> deployment/meta repo? Ties into the `ohm-rfc-app-flotilla` operator tooling,
> which already owns deploy orchestration and could own registry edits.
## 2. Data model
Introduce a **`projects`** cache table (mirrored from the registry, like
`cached_rfcs` is mirrored from content). Then thread `project_id` through
every RFC-scoped table.
Hard constraint: once slugs are unique only *within* a project, any table
keyed on `rfc_slug` alone is ambiguous, so `project_id` must ride along on all
of them:
- `cached_rfcs` — PK becomes `(project_id, slug)`; `rfc_id` unique per project
- `cached_branches`, `cached_prs`, `pr_resolution_branches`, `proposed_use_cases`
- `threads`, `changes`, `branch_visibility`, `branch_contribute_grants`
- `stars`, `watches`, `pr_seen`, `branch_chat_seen`
- `rfc_invitations`, `rfc_collaborators`, `contribution_requests`, `funder_consents`
- `notifications`, `actions`
~15-table migration, all backfillable to a single default project (see §7).
With slug-only identity (Decision 3) there is no per-project number to
allocate: `api_graduation.py`'s `RFC-NNNN` allocator is **retired**, and
graduation reduces to the status flip + content-repo move, keyed on
`(project_id, slug)`.
New tables:
```
projects(
id TEXT PRIMARY KEY, -- 'ohm'
name TEXT NOT NULL,
type TEXT NOT NULL -- document | specification | bdd (immutable)
CHECK (type IN ('document','specification','bdd')),
initial_state TEXT NOT NULL -- super-draft | active (landing state, §types)
DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active')),
content_repo TEXT NOT NULL,
visibility TEXT CHECK (visibility IN ('gated','public','unlisted')),
config_json TEXT, -- theme, tagline, enabled_models, …
registry_sha TEXT, -- provenance of the mirrored row
updated_at TEXT
)
project_members(
project_id TEXT NOT NULL REFERENCES projects(id),
user_id INTEGER NOT NULL REFERENCES users(id),
role TEXT CHECK (role IN ('project_admin','project_contributor','project_viewer')),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT,
PRIMARY KEY (project_id, user_id)
)
```
## 3. Roles — three tiers
A **middle tier** slots between today's deployment roles and per-RFC authority.
| Tier | Who | Powers |
|---|---|---|
| **Deployment** (unchanged, narrowed) | `owner` / `admin` | Create/archive projects, manage all accounts, act in any project. The beta/`permission_state` gate stays here — it gates *having an account*, not project access. A plain authenticated user has an account but no implicit project powers. |
| **Project** (NEW — `project_members`) | `project_admin` / `project_contributor` / `project_viewer` | `project_admin` = today's app-admin, scoped to one project (manage its membership, settings, graduate, act on any RFC in it). `project_contributor` = propose/branch/PR/chat. `project_viewer` = read + discuss only. |
| **RFC** (unchanged) | frontmatter `owners`/`arbiters`; `rfc_collaborators` (`contributor`/`discussant`) | Same as today, now scoped within their project. |
This is the existing owner → admin → contributor delegation pattern with a
project axis added. `users.role` reverts to meaning *deployment*-level only.
**Visibility interaction:**
- **gated** (default) — invisible to non-members; must be a member to see it
exists. Read and write both require membership.
- **public** — any authenticated user can read; contributing requires a
`project_contributor` grant.
- **unlisted** — readable by direct link, not shown in the directory.
> Philosophy tension to resolve in SPEC: today the app is open-by-default
> (anonymous read, §11.1; admission gates only writing). Gated-by-default
> reverses that for the common case. The `public`/`unlisted` modes preserve the
> old behavior for projects that want it, and the deployment can choose its own
> default posture — but §11 and §14 need rewriting to make "gated" the baseline
> and anonymous read a per-project opt-in.
**Discovery for gated projects:** since a stranger sees an empty directory,
there must be a join path — invite-only (a `project_admin` adds you), or a
request-to-join surface analogous to the existing §28 contribution-request
flow. (Open — pick one.)
## 4. Branding & frontend (forced change)
The one *forced* change. `VITE_APP_NAME` is baked at build time; you cannot
bake N project names into one bundle. Branding moves to **runtime config
served by the backend**:
- `GET /api/deployment` → deployment name/tagline + the list of projects the
caller can see (gated ones filtered by membership).
- `GET /api/projects/:id` → that project's name, tagline, philosophy, theme
tokens.
- Frontend reads these instead of `import.meta.env.VITE_APP_NAME`
(`App.jsx:208`, `Landing.jsx:16`, `BetaPending.jsx:17`).
`VITE_APP_NAME` is deprecated → deployment name comes from the registry. This
is a documented config change with upgrade steps per CLAUDE.md.
Two chrome layers result:
- **Deployment chrome** — the Wiggleverse header, the project directory /
landing, a project switcher.
- **Project chrome** — the current header / catalog / philosophy, now per
project; theme tokens (`tokens.css`) overridable per project at runtime.
## 5. Routing & UX
- Entry routes gain a project prefix with a **generic `/e/` segment**:
`/p/<project>/e/<slug>`, `/p/<project>/proposals/<n>`,
`/p/<project>/philosophy`, etc. The segment is the same for every type; the
noun shown around the slug is a type-driven label, not part of the path.
- Root `/` becomes the **deployment landing = project directory** (the
Wiggleverse home). For an anonymous or non-member visitor under gated
default, that's only public/unlisted-by-link projects.
- Breadcrumb gains a segment: `Wiggleverse / OHM / Human main` (the entry
is named by its slug; the entry-noun the chrome uses around it is
type-driven — "RFC", "Spec", "Feature").
- The left-pane catalog (§7) becomes per-project; a project switcher lives in
deployment chrome.
## 6. Cross-cutting — notifications, inbox, watches
Accounts are deployment-wide, so there is **one inbox spanning projects**
(§15). `notifications` and `watches` carry `project_id` so the inbox is
filterable and a user can mute an entire project. Quiet hours / digest prefs
stay per-account (deployment level).
## 7. Backward compatibility & migration
The N=1 path keeps existing single-project deployments working:
1. Migration creates one **default project** from current config (`META_REPO`
`content_repo`, `VITE_APP_NAME``name`, visibility seeded to match the
deployment's current open posture, likely `public`). Its `id` is a
config-derived slug (`DEFAULT_PROJECT_ID`, else slug of the deployment
name, else `default`); M1's `default` bootstrap id is re-stamped to it in
M3 before any `/p/` URL is public.
2. Every existing row's `project_id` is stamped to that default project.
3. An optional default-project redirect keeps old `/rfc/<slug>` URLs alive
(308 → `/p/<default-id>/e/<slug>`).
This is the SPEC §20 upgrade-steps block.
## 8. SPEC & versioning impact
Touches §1, §2, §5, §6, §7, §8, §13, §14, §17, §20. Pre-1.0 minor with
breaking changes spelled out (schema migration, `VITE_APP_NAME` deprecation,
URL change, gated-default philosophy shift). Single-process SQLite stays fine —
`project_id` is just a column; no DB-per-project, no Postgres forced.
## Operator-tooling integration (flotilla)
Checked against the `ohm-rfc-app-flotilla` spec (the OHM deployment's operator
control panel). It assembles a deployment from `{rfc-app@pin} + {non-secret
overlay} + {secret pulls} + {corpus}`, **does not host the corpus** ("the
corpus lives in a deployment-side repo"), and **depends on the framework only
through versioned contracts** (`/api/health`, `VERSION`, CHANGELOG
upgrade-steps, the `.rfc-app-version` pin). The framework knows nothing about
flotilla.
This confirms the registry decision and fixes how it integrates:
- The registry is **deployment-side git content the framework reads** — same
category as the corpus — *not* a flotilla-owned config blob. Putting
project definitions in operator tooling would re-bake deployment specifics
into the assembly layer and add a new framework⇄tool contract.
- The framework reads the registry via a `REGISTRY_REPO` env var, the
multi-project successor to `META_REPO`. Flotilla's overlay simply swaps one
ref; its four versioned contracts are untouched.
- Multi-project therefore ships to OHM as an **ordinary pinned-version
upgrade**: bump the pin, run the §22.13 default-project migration, change
`META_REPO``REGISTRY_REPO` in the overlay, deploy, verify via
`/api/health`. No flotilla architectural change.
## Open questions
- Gated discovery: invite-only vs. request-to-join surface? (drafted with
both; M5 can ship invite-only first.)
- Does the deployment landing itself need branding config, or is it derived
entirely from the registry `deployment:` block?
- Per-project `ENABLED_MODELS` resolution vs. deployment universe (§18, §6.6,
§6.7 funder) — confirm fallback order.
- Slicing plan for the build (mirrors DEV.md's original slice approach).
- **Type surfaces — depth of each.** What concretely is in the
`specification` *release-planning* surface (its own tables? a release =
a tag + a changelog entry + a set of graduated entries?), and does the
`bdd` scenario model stay free-form markdown or get a structured
Given/When/Then schema the app parses? Drafted shallow; pin before the
type-surface slice.
- **Entry-noun in URLs/labels***resolved 2026-06-02:* generic route
segment `/p/<project>/e/<slug>` for every type; the displayed noun
("RFC"/"Spec"/"Feature") is a type-driven label, not part of the path
(§22.10, §22.4a).
- **Existing graduated numbers***resolved 2026-06-02:* pre-change graduated
entries keep their `RFC-NNNN` `id` in frontmatter as a frozen, read-only
legacy display label (preserves citations); never used for lookup, never
assigned to new entries (§22.4).
- **Default project `id`***resolved 2026-06-02:* a config-derived slug
(`DEFAULT_PROJECT_ID`, else slug of the deployment name, else `default`);
M1's `default` bootstrap id is re-stamped in M3 before any `/p/` URL is
public, so it's meaningful (e.g. `/p/ohm/`) and never renamed live (§22.13).