§22 S1: thread collection_id through backend + update tests (N=1 unchanged, 454 green)

- collections.py resolution helpers (default_collection_id, type, initial_state)
- registry mirror writes project grouping fields + default-collection corpus fields
- auth.project_of_rfc joins collections; project_member_role reads memberships
- cache/api_*/funder writers+readers re-keyed to collection_id (cached_prs + denormalised
  project_id tags unchanged); api_deployment reads type/initial_state from the default collection
- projects.py restamp detects bootstrap via collections; initial_state via collection
- tests updated to the three-tier schema; test_migration_028 retired (superseded by 029)
- add @S1 acceptance test (collection grain + N=1 serving)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-05 08:26:14 -07:00
parent 867f2504d6
commit 9ca07a3f81
27 changed files with 475 additions and 301 deletions
+31 -25
View File
@@ -29,6 +29,7 @@ from . import (
api_notifications, api_notifications,
api_prs, api_prs,
auth, auth,
collections as collections_mod,
projects as projects_mod, projects as projects_mod,
db, db,
device_trust as device_trust_mod, device_trust as device_trust_mod,
@@ -637,13 +638,13 @@ def make_router(
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'" unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
rows = db.conn().execute( rows = db.conn().execute(
f""" f"""
SELECT slug, title, state, rfc_id, repo, SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
owners_json, arbiters_json, tags_json, r.owners_json, r.arbiters_json, r.tags_json,
last_main_commit_at, last_entry_commit_at, updated_at r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
FROM cached_rfcs FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
WHERE state IN ('super-draft', 'active') WHERE r.state IN ('super-draft', 'active')
AND project_id IN ({placeholders}){unreviewed_clause} AND c.project_id IN ({placeholders}){unreviewed_clause}
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC
""", """,
params, params,
).fetchall() ).fetchall()
@@ -685,8 +686,8 @@ def make_router(
raise HTTPException(404, "Not found") raise HTTPException(404, "Not found")
viewer = auth.current_user(request) viewer = auth.current_user(request)
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries # §22.5 visibility gate (subtractive, §22.7): a gated project's entries
# 404 to non-members. # 404 to non-members. Recover the project via the entry's collection.
auth.require_project_readable(viewer, row["project_id"]) auth.require_project_readable(viewer, auth.project_of_rfc(slug))
# §13.7: a retired entry is removed from every browsing surface. The # §13.7: a retired entry is removed from every browsing surface. The
# sole exception is a site owner, so the un-retire affordance has # sole exception is a site owner, so the un-retire affordance has
# somewhere to live; everyone else gets a plain 404. # somewhere to live; everyone else gets a plain 404.
@@ -723,6 +724,8 @@ def make_router(
viewer = auth.current_user(request) viewer = auth.current_user(request)
# §22.5 read gate: a gated project's catalog 404s to a non-member. # §22.5 read gate: a gated project's catalog 404s to a non-member.
auth.require_project_readable(viewer, project_id) auth.require_project_readable(viewer, project_id)
# §22 S1: serve the project's default collection (the corpus grain).
collection_id = collections_mod.default_collection_id(project_id)
viewer_id = viewer.user_id if viewer else None viewer_id = viewer.user_id if viewer else None
unreviewed_clause = "" unreviewed_clause = ""
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"): if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
@@ -734,18 +737,18 @@ def make_router(
last_main_commit_at, last_entry_commit_at, updated_at last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs FROM cached_rfcs
WHERE state IN ('super-draft', 'active') WHERE state IN ('super-draft', 'active')
AND project_id = ?{unreviewed_clause} AND collection_id = ?{unreviewed_clause}
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
""", """,
(project_id,), (collection_id,),
).fetchall() ).fetchall()
starred = set() starred = set()
if viewer_id is not None: if viewer_id is not None:
starred = { starred = {
r["rfc_slug"] r["rfc_slug"]
for r in db.conn().execute( for r in db.conn().execute(
"SELECT rfc_slug FROM stars WHERE user_id = ? AND project_id = ?", "SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
(viewer_id, project_id), (viewer_id, collection_id),
) )
} }
items = [ items = [
@@ -770,9 +773,10 @@ def make_router(
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]: async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request) viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id) auth.require_project_readable(viewer, project_id)
collection_id = collections_mod.default_collection_id(project_id)
row = db.conn().execute( row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?", "SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
(project_id, slug), (collection_id, slug),
).fetchone() ).fetchone()
if row is None: if row is None:
raise HTTPException(404, "Not found") raise HTTPException(404, "Not found")
@@ -782,10 +786,10 @@ def make_router(
uc = db.conn().execute( uc = db.conn().execute(
""" """
SELECT use_case FROM proposed_use_cases SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ? AND project_id = ? WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
ORDER BY id DESC LIMIT 1 ORDER BY id DESC LIMIT 1
""", """,
(slug, project_id), (slug, collection_id),
).fetchone() ).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload return payload
@@ -802,9 +806,10 @@ def make_router(
auth.require_project_readable(viewer, project_id) auth.require_project_readable(viewer, project_id)
if not auth.is_project_superuser(viewer, project_id): if not auth.is_project_superuser(viewer, project_id):
raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed") raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed")
collection_id = collections_mod.default_collection_id(project_id)
row = db.conn().execute( row = db.conn().execute(
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND project_id = ?", "SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
(slug, project_id), (slug, collection_id),
).fetchone() ).fetchone()
if row is None: if row is None:
raise HTTPException(404, "Not found") raise HTTPException(404, "Not found")
@@ -965,8 +970,9 @@ def make_router(
# We re-check atomically here even though the client also checks # We re-check atomically here even though the client also checks
# on every keystroke, since a concurrent submission could land # on every keystroke, since a concurrent submission could land
# between dialog-open and submit. # between dialog-open and submit.
collection_id = collections_mod.default_collection_id(project_id)
clash = db.conn().execute( clash = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ? AND project_id = ?", (slug, project_id) "SELECT 1 FROM cached_rfcs WHERE slug = ? AND collection_id = ?", (slug, collection_id)
).fetchone() ).fetchone()
if clash: if clash:
raise HTTPException(409, f"Slug `{slug}` is already taken") raise HTTPException(409, f"Slug `{slug}` is already taken")
@@ -1041,11 +1047,11 @@ def make_router(
if use_case: if use_case:
db.conn().execute( db.conn().execute(
""" """
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, project_id) INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, collection_id)
VALUES ('rfc', ?, ?, ?, ?) VALUES ('rfc', ?, ?, ?, ?)
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""", """,
(slug, pr["number"], use_case, project_id), (slug, pr["number"], use_case, collection_id),
) )
db.conn().execute( db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?", "UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
@@ -1205,12 +1211,12 @@ def make_router(
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]: async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request) user = auth.require_contributor(request)
rfc = db.conn().execute( rfc = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,) "SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone() ).fetchone()
if rfc is None: if rfc is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members. # §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(user, rfc["project_id"]) auth.require_project_readable(user, auth.project_of_rfc(slug))
# §6.7: refuse consent from a user with no registered credentials # §6.7: refuse consent from a user with no registered credentials
# — a consent without a universe would be inert and the surface # — a consent without a universe would be inert and the surface
# should fail loudly rather than silently. # should fail loudly rather than silently.
+3 -3
View File
@@ -742,7 +742,7 @@ def make_router(
""" """
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, ?, ?) VALUES (?, ?, ?, ?)
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
read_public = excluded.read_public, read_public = excluded.read_public,
contribute_mode = excluded.contribute_mode contribute_mode = excluded.contribute_mode
""", """,
@@ -896,7 +896,7 @@ def make_router(
""" """
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at) INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(project_id, user_id, rfc_slug, branch_name) DO UPDATE SET ON CONFLICT(collection_id, user_id, rfc_slug, branch_name) DO UPDATE SET
last_seen_message_id = excluded.last_seen_message_id, last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at seen_at = excluded.seen_at
""", """,
@@ -1092,7 +1092,7 @@ def make_router(
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _require_rfc(slug: str, viewer): def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None: if row is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries # §22.5 visibility gate (subtractive, §22.7): a gated project's entries
+2 -2
View File
@@ -62,7 +62,7 @@ def _require_super_draft(slug: str, viewer):
visibility gate is subtractive: a gated project's entries 404 to visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7).""" non-members (§22.7)."""
row = db.conn().execute( row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?", "SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
(slug,), (slug,),
).fetchone() ).fetchone()
if row is None: if row is None:
@@ -108,7 +108,7 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/contribution-target") @router.get("/api/rfcs/{slug}/contribution-target")
async def contribution_target(slug: str, request: Request) -> dict[str, Any]: async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute( row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?", "SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
(slug,), (slug,),
).fetchone() ).fetchone()
if row is None: if row is None:
+27 -10
View File
@@ -18,7 +18,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from . import auth, db, projects as projects_mod from . import auth, collections as collections_mod, db, projects as projects_mod
from .config import Config from .config import Config
@@ -33,12 +33,21 @@ def make_router(config: Config) -> APIRouter:
).fetchone() ).fetchone()
# §22.5: enumerate only public + (member-)gated; unlisted is never listed. # §22.5: enumerate only public + (member-)gated; unlisted is never listed.
visible = set(auth.visible_project_ids(viewer)) visible = set(auth.visible_project_ids(viewer))
# §22 three-tier: `type` is a per-corpus field on the (default) collection
# now; surface the default collection's type for each project.
rows = db.conn().execute( rows = db.conn().execute(
"SELECT id, name, type, visibility FROM projects " "SELECT id, name, visibility FROM projects "
"WHERE visibility != 'unlisted' ORDER BY name" "WHERE visibility != 'unlisted' ORDER BY name"
).fetchall() ).fetchall()
projects = [ projects = [
{"id": r["id"], "name": r["name"], "type": r["type"], "visibility": r["visibility"]} {
"id": r["id"],
"name": r["name"],
"type": collections_mod.collection_type(
collections_mod.default_collection_id(r["id"])
),
"visibility": r["visibility"],
}
for r in rows for r in rows
if r["id"] in visible if r["id"] in visible
] ]
@@ -60,8 +69,7 @@ def make_router(config: Config) -> APIRouter:
# an unknown id). unlisted is readable by direct id. # an unknown id). unlisted is readable by direct id.
auth.require_project_readable(viewer, project_id) auth.require_project_readable(viewer, project_id)
row = db.conn().execute( row = db.conn().execute(
"SELECT id, name, type, visibility, initial_state, config_json " "SELECT id, name, visibility, config_json FROM projects WHERE id = ?",
"FROM projects WHERE id = ?",
(project_id,), (project_id,),
).fetchone() ).fetchone()
if row is None: if row is None:
@@ -71,13 +79,16 @@ def make_router(config: Config) -> APIRouter:
except (ValueError, TypeError): except (ValueError, TypeError):
cfg = {} cfg = {}
dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone() dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone()
# §22 three-tier: type + initial_state moved down to the (default)
# collection in migration 029.
cid = collections_mod.default_collection_id(row["id"])
return { return {
"id": row["id"], "id": row["id"],
"name": row["name"], "name": row["name"],
"tagline": (dep["tagline"] if dep else "") or "", "tagline": (dep["tagline"] if dep else "") or "",
"type": row["type"], "type": collections_mod.collection_type(cid),
"visibility": row["visibility"], "visibility": row["visibility"],
"initial_state": row["initial_state"], "initial_state": collections_mod.collection_initial_state(cid),
"theme": cfg.get("theme") or {}, "theme": cfg.get("theme") or {},
} }
@@ -86,21 +97,27 @@ def make_router(config: Config) -> APIRouter:
# is permanent, so external "RFC-0001" links and bookmarks land correctly. # is permanent, so external "RFC-0001" links and bookmarks land correctly.
# nginx routes /rfc/ and /proposals/ to the backend so these are reached # nginx routes /rfc/ and /proposals/ to the backend so these are reached
# before the SPA's index.html fallback. # before the SPA's index.html fallback.
# §22 three-tier (S1): the canonical entry route now carries the collection
# segment /p/<project>/c/<collection>/…. The legacy roots redirect through
# the default project's default collection.
@router.get("/rfc/{slug}") @router.get("/rfc/{slug}")
async def redirect_old_rfc(slug: str) -> RedirectResponse: async def redirect_old_rfc(slug: str) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config) default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(url=f"/p/{default_id}/e/{slug}", status_code=308) cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/e/{slug}", status_code=308)
@router.get("/rfc/{slug}/pr/{pr_number}") @router.get("/rfc/{slug}/pr/{pr_number}")
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse: async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config) default_id = projects_mod.resolved_default_id(config)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse( return RedirectResponse(
url=f"/p/{default_id}/e/{slug}/pr/{pr_number}", status_code=308 url=f"/p/{default_id}/c/{cid}/e/{slug}/pr/{pr_number}", status_code=308
) )
@router.get("/proposals/{pr_number}") @router.get("/proposals/{pr_number}")
async def redirect_old_proposal(pr_number: int) -> RedirectResponse: async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config) default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(url=f"/p/{default_id}/proposals/{pr_number}", status_code=308) cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/proposals/{pr_number}", status_code=308)
return router return router
+1 -1
View File
@@ -252,7 +252,7 @@ def _require_rfc_readable(slug: str, viewer):
entries refuse reads of every shape — same rule `_require_rfc_with_repo` entries refuse reads of every shape — same rule `_require_rfc_with_repo`
in `api_branches.py` follows.""" in `api_branches.py` follows."""
row = db.conn().execute( row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,) "SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone() ).fetchone()
if row is None: if row is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
+3 -3
View File
@@ -574,7 +574,7 @@ def make_router(
# ------------------------------------------------------------------- # -------------------------------------------------------------------
def _require_super_draft(slug: str, viewer): def _require_super_draft(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None: if row is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members. # §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members.
@@ -584,7 +584,7 @@ def make_router(
return row return row
def _require_retirable(slug: str): def _require_retirable(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None: if row is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
if row["state"] not in ("super-draft", "active"): if row["state"] not in ("super-draft", "active"):
@@ -592,7 +592,7 @@ def make_router(
return row return row
def _require_retired(slug: str): def _require_retired(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None: if row is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
if row["state"] != "retired": if row["state"] != "retired":
+1 -1
View File
@@ -380,7 +380,7 @@ def _require_rfc(slug: str, viewer):
visibility gate is subtractive: a gated project's entries 404 to visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7).""" non-members (§22.7)."""
row = db.conn().execute( row = db.conn().execute(
"SELECT slug, title, state, project_id FROM cached_rfcs WHERE slug = ?", (slug,), "SELECT slug, title, state, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,),
).fetchone() ).fetchone()
if row is None: if row is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
+2 -2
View File
@@ -213,7 +213,7 @@ def make_router(config: Config) -> APIRouter:
@router.post("/api/rfcs/{slug}/watch") @router.post("/api/rfcs/{slug}/watch")
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]: async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request) viewer = auth.require_user(request)
rfc = db.conn().execute("SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() rfc = db.conn().execute("SELECT (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if rfc is None: if rfc is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members. # §22.5 visibility gate (subtractive): gated → 404 to non-members.
@@ -222,7 +222,7 @@ def make_router(config: Config) -> APIRouter:
""" """
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at) INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now')) VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
ON CONFLICT(project_id, user_id, rfc_slug) DO UPDATE SET ON CONFLICT(collection_id, user_id, rfc_slug) DO UPDATE SET
state = excluded.state, state = excluded.state,
set_by = 'explicit', set_by = 'explicit',
set_at = excluded.set_at set_at = excluded.set_at
+4 -4
View File
@@ -153,7 +153,7 @@ def make_router(
""" """
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, 1, 'just-me') VALUES (?, ?, 1, 'just-me')
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1 ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
""", """,
(slug, branch), (slug, branch),
) )
@@ -189,7 +189,7 @@ def make_router(
""" """
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('pr', ?, ?, ?) VALUES ('pr', ?, ?, ?)
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""", """,
(slug, pr["number"], use_case), (slug, pr["number"], use_case),
) )
@@ -398,7 +398,7 @@ def make_router(
INSERT INTO pr_seen INSERT INTO pr_seen
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at) (user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, ?, datetime('now')) VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(project_id, user_id, rfc_slug, pr_number) DO UPDATE SET ON CONFLICT(collection_id, user_id, rfc_slug, pr_number) DO UPDATE SET
last_seen_commit_sha = excluded.last_seen_commit_sha, last_seen_commit_sha = excluded.last_seen_commit_sha,
last_seen_message_id = excluded.last_seen_message_id, last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at seen_at = excluded.seen_at
@@ -662,7 +662,7 @@ def make_router(
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _require_rfc(slug: str, viewer): def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None: if row is None:
raise HTTPException(404, "RFC not found") raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always # §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
+27 -10
View File
@@ -337,24 +337,41 @@ def project_visibility(project_id: str) -> str:
def project_member_role(user: SessionUser | None, project_id: str) -> str | None: 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 """The user's *explicit* §22.6 membership role at this project, or None.
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).""" §22 three-tier (S1): M2's `project_members` rows migrated into the unified
`memberships` table at the project's default collection (and the project
tier is freshly grantable). The unified `{owner, contributor}` roles are
mapped back to the legacy `project_admin`/`project_contributor` strings the
S1 project-grain authz still speaks; the four-layer scope resolver lands in
S3. Reads the stored grant only (project OR default-collection scope) it
does not fold in the deployment tier or the implicit-on-public baseline."""
if user is None: if user is None:
return None return None
from . import collections as collections_mod
cid = collections_mod.default_collection_id(project_id)
row = db.conn().execute( row = db.conn().execute(
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?", "SELECT role FROM memberships "
(project_id, user.user_id), "WHERE user_id = ? AND ((scope_type = 'project' AND scope_id = ?) "
" OR (scope_type = 'collection' AND scope_id = ?)) "
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
(user.user_id, project_id, cid),
).fetchone() ).fetchone()
return row["role"] if row else None if row is None:
return None
return "project_admin" if row["role"] == "owner" else "project_contributor"
def project_of_rfc(rfc_slug: str) -> str: def project_of_rfc(rfc_slug: str) -> str:
"""The project an RFC belongs to (`cached_rfcs.project_id`). Falls back to """The project an RFC belongs to, via its collection
the default project when the slug isn't cached or the column is unset — the (`cached_rfcs.collection_id` -> `collections.project_id`, §22 three-tier).
same N=1 default migration 026 backfills.""" Falls back to the default project when the slug isn't cached — the same N=1
default migration 026 backfills."""
row = db.conn().execute( row = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,) "SELECT c.project_id AS project_id "
"FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id "
"WHERE r.slug = ?",
(rfc_slug,),
).fetchone() ).fetchone()
if row is None: if row is None:
return DEFAULT_PROJECT_ID return DEFAULT_PROJECT_ID
+14 -9
View File
@@ -54,6 +54,11 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None: async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
# §22 S1: the corpus grain is the collection. The mirror is project-grained
# (reads rfcs/ at the repo root = the project's default collection); resolve
# that collection once and key cached_rfcs by it.
from . import collections as collections_mod
collection_id = collections_mod.default_collection_id(project_id)
try: try:
files = await gitea.list_dir(org, repo, "rfcs", ref="main") files = await gitea.list_dir(org, repo, "rfcs", ref="main")
except GiteaError as e: except GiteaError as e:
@@ -77,7 +82,7 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"]) log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
continue continue
seen_slugs.add(entry.slug) seen_slugs.add(entry.slug)
_upsert_cached_rfc(entry, body_sha=sha, project_id=project_id) _upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries # Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
# as historical record (§3), so this fires only for out-of-band deletes; # as historical record (§3), so this fires only for out-of-band deletes;
@@ -85,14 +90,14 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
existing = { existing = {
row["slug"] row["slug"]
for row in db.conn().execute( for row in db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE project_id = ?", (project_id,) "SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,)
) )
} }
for missing in existing - seen_slugs: for missing in existing - seen_slugs:
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing) log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing)
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str = "default") -> None: def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
# §6.6: models_json stays NULL when the frontmatter key is absent # §6.6: models_json stays NULL when the frontmatter key is absent
# (inherit operator universe) and '[]' for the explicit opt-out. # (inherit operator universe) and '[]' for the explicit opt-out.
models_json = json.dumps(entry.models) if entry.models is not None else None models_json = json.dumps(entry.models) if entry.models is not None else None
@@ -105,10 +110,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str =
(slug, title, state, rfc_id, repo, proposed_by, proposed_at, (slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json, graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
models_json, funder_login, body, body_sha, models_json, funder_login, body, body_sha,
unreviewed, reviewed_at, reviewed_by, project_id, unreviewed, reviewed_at, reviewed_by, collection_id,
last_entry_commit_at, updated_at) last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(project_id, slug) DO UPDATE SET ON CONFLICT(collection_id, slug) DO UPDATE SET
title = excluded.title, title = excluded.title,
state = excluded.state, state = excluded.state,
rfc_id = excluded.rfc_id, rfc_id = excluded.rfc_id,
@@ -150,7 +155,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str =
1 if entry.unreviewed else 0, 1 if entry.unreviewed else 0,
entry.reviewed_at, entry.reviewed_at,
entry.reviewed_by, entry.reviewed_by,
project_id, collection_id,
), ),
) )
@@ -210,7 +215,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
""" """
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?) VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha, head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at last_commit_at = excluded.last_commit_at
@@ -385,7 +390,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
""" """
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?) VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha, head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at last_commit_at = excluded.last_commit_at
@@ -407,7 +412,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
""" """
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at) INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?) VALUES (?, 'main', ?, 'open', ?)
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha, head_sha = excluded.head_sha,
last_commit_at = excluded.last_commit_at last_commit_at = excluded.last_commit_at
""", """,
+50
View File
@@ -0,0 +1,50 @@
"""§22 collection grain — resolution helpers beneath the project tier.
In S1 each project has exactly one collection (the default). These helpers
recover the collection for a project and read the per-corpus fields (`type`,
`initial_state`) that moved down from `projects` in migration 029. Project-grain
authz (auth.py) recovers a row's project by joining `collections` on
`collection_id`.
"""
from __future__ import annotations
from . import db
DEFAULT_COLLECTION_ID = "default"
def default_collection_id(project_id: str) -> str:
"""The id of a project's default (S1: sole) collection. Falls back to the
literal 'default' when the project has no collection row yet."""
row = db.conn().execute(
"SELECT id FROM collections WHERE project_id = ? ORDER BY created_at, id LIMIT 1",
(project_id,),
).fetchone()
return row["id"] if row else DEFAULT_COLLECTION_ID
def project_of_collection(collection_id: str) -> str | None:
"""The project a collection belongs to, or None if unknown."""
row = db.conn().execute(
"SELECT project_id FROM collections WHERE id = ?", (collection_id,)
).fetchone()
return row["project_id"] if row else None
def collection_initial_state(collection_id: str) -> str:
"""§22.4b landing state for new entries in a collection. 'super-draft'
default for an unknown/unset row (today's safe flow)."""
row = db.conn().execute(
"SELECT initial_state FROM collections WHERE id = ?", (collection_id,)
).fetchone()
if row is None or not row["initial_state"]:
return "super-draft"
return row["initial_state"]
def collection_type(collection_id: str) -> str:
"""The collection's immutable §22.4a type. 'document' default for unknown."""
row = db.conn().execute(
"SELECT type FROM collections WHERE id = ?", (collection_id,)
).fetchone()
return row["type"] if row and row["type"] else "document"
+1 -1
View File
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
db.conn().execute( db.conn().execute(
""" """
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?) INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
ON CONFLICT(project_id, user_id, rfc_slug) DO NOTHING ON CONFLICT(collection_id, user_id, rfc_slug) DO NOTHING
""", """,
(user_id, slug), (user_id, slug),
) )
+11 -9
View File
@@ -43,8 +43,11 @@ def restamp_default_project(config: Config) -> None:
if target == DEFAULT_PROJECT_ID: if target == DEFAULT_PROJECT_ID:
return return
conn = db.conn() conn = db.conn()
# §22 three-tier: the entry-corpus tables key on collection_id now; detect a
# lingering bootstrap project by the project-grain `collections.project_id`
# (the PRAGMA scan below still renames every project_id column dynamically).
has_rows = conn.execute( has_rows = conn.execute(
"SELECT 1 FROM cached_rfcs WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,) "SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
).fetchone() ).fetchone()
stale_proj = conn.execute( stale_proj = conn.execute(
"SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,) "SELECT 1 FROM projects WHERE id = ? LIMIT 1", (DEFAULT_PROJECT_ID,)
@@ -110,11 +113,10 @@ def content_repo(project_id: str) -> str | None:
def project_initial_state(project_id: str) -> str: def project_initial_state(project_id: str) -> str:
"""§22.4b landing state for new entries in a project. Defaults to """§22.4b landing state for new entries in a project's default collection
'super-draft' for an unknown/unset row (the safe, today's-flow default).""" (the per-corpus field moved down to the collection in migration 029).
row = db.conn().execute( Defaults to 'super-draft' for an unknown/unset row (today's-flow default)."""
"SELECT initial_state FROM projects WHERE id = ?", (project_id,) from . import collections as collections_mod
).fetchone() return collections_mod.collection_initial_state(
if row is None or not row["initial_state"]: collections_mod.default_collection_id(project_id)
return "super-draft" )
return row["initial_state"]
+51 -20
View File
@@ -114,44 +114,74 @@ def parse_registry(text: str) -> RegistryDoc:
) )
def apply_registry(doc: RegistryDoc, registry_sha: str) -> None: def _default_collection_id(project_id: str, default_id: str) -> str:
"""Upsert the parsed registry into projects + deployment. Idempotent. """The id of a project's default collection. The deployment's primary
project (== `default_id`, the §22.13 resolved default) gets the stable
literal `'default'` matching migration 029's seed so the upsert *merges*
onto the migration-seeded row rather than duplicating it (critical on a
fresh deploy where the bootstrap `default` project is later restamped to the
configured id). Any additional project keys its default collection by its own
id, keeping the collection PK globally unique (pre-S5 multi-project)."""
return "default" if project_id == default_id else project_id
§22.4a: `type` is immutable a change against an existing row is rejected
(skip + log), never applied. Projects absent from the registry are left in def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None:
place (archival is out of scope for M3; they simply stop refreshing). """Upsert the parsed registry into projects + their default collections +
the deployment singleton. Idempotent.
§22 three-tier (S1): a project carries the grouping-tier fields (name,
content_repo, visibility, config); the per-corpus fields (`type`,
`initial_state`) live on the project's default collection. §22.4a: `type` is
immutable a change against an existing collection is rejected (skip the
type change + log), never applied. Projects absent from the registry are
left in place (archival is out of scope for M3; they stop refreshing).
""" """
with db.tx() as conn: with db.tx() as conn:
for e in doc.projects: for e in doc.projects:
cid = _default_collection_id(e.id, default_id)
existing = conn.execute( existing = conn.execute(
"SELECT type FROM projects WHERE id = ?", (e.id,) "SELECT type FROM collections WHERE id = ?", (cid,)
).fetchone() ).fetchone()
if existing is not None and existing["type"] != e.type: type_locked = existing is not None and existing["type"] != e.type
if type_locked:
log.error( log.error(
"registry: refusing immutable type change on project %s (%s -> %s)", "registry: refusing immutable type change on collection %s (%s -> %s)",
e.id, existing["type"], e.type, cid, existing["type"], e.type,
) )
continue # The project (grouping tier) always refreshes.
conn.execute( conn.execute(
""" """
INSERT INTO projects INSERT INTO projects
(id, name, type, content_repo, visibility, initial_state, (id, name, content_repo, visibility, config_json, registry_sha, updated_at)
config_json, registry_sha, updated_at) VALUES (?, ?, ?, ?, ?, ?, datetime('now'))
VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET ON CONFLICT(id) DO UPDATE SET
name = excluded.name, name = excluded.name,
type = excluded.type,
content_repo = excluded.content_repo, content_repo = excluded.content_repo,
visibility = excluded.visibility, visibility = excluded.visibility,
initial_state = excluded.initial_state,
config_json = excluded.config_json, config_json = excluded.config_json,
registry_sha = excluded.registry_sha, registry_sha = excluded.registry_sha,
updated_at = datetime('now') updated_at = datetime('now')
""", """,
( (e.id, e.name, e.content_repo, e.visibility, json.dumps(e.config), registry_sha),
e.id, e.name, e.type, e.content_repo, e.visibility, )
e.initial_state, json.dumps(e.config), registry_sha, # The default collection (corpus tier). On an immutable-type
), # conflict, keep the stored type but still refresh the rest.
effective_type = existing["type"] if type_locked else e.type
conn.execute(
"""
INSERT INTO collections
(id, project_id, type, subfolder, initial_state, visibility, name, registry_sha, updated_at)
VALUES (?, ?, ?, '', ?, ?, ?, ?, datetime('now'))
ON CONFLICT(id) DO UPDATE SET
project_id = excluded.project_id,
type = excluded.type,
initial_state = excluded.initial_state,
visibility = excluded.visibility,
name = excluded.name,
registry_sha = excluded.registry_sha,
updated_at = datetime('now')
""",
(cid, e.id, effective_type, e.initial_state, e.visibility, e.name, registry_sha),
) )
conn.execute( conn.execute(
""" """
@@ -181,5 +211,6 @@ async def refresh_registry(config: Config, gitea: Gitea) -> None:
# includes it on the contents response); fall back to the blob sha. # includes it on the contents response); fall back to the blob sha.
sha = item.get("last_commit_sha") or item.get("sha") or "" sha = item.get("last_commit_sha") or item.get("sha") or ""
doc = parse_registry(text) doc = parse_registry(text)
apply_registry(doc, sha) from . import projects as projects_mod
apply_registry(doc, sha, projects_mod.resolved_default_id(config))
log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha) log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha)
+14 -7
View File
@@ -9,11 +9,18 @@ from test_propose_vertical import ( # noqa: F401
def _add_project(pid, name, vis, typ="document"): def _add_project(pid, name, vis, typ="document"):
# §22 three-tier: a project (grouping tier) + its default collection (the
# per-corpus type/initial_state moved down in migration 029). The default
# collection keys by the project id so it is globally unique in tests.
from app import db from app import db
db.conn().execute( db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, type, content_repo, visibility, initial_state) " "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
"VALUES (?, ?, ?, ?, ?, 'super-draft')", (pid, name, pid, vis),
(pid, name, typ, pid, vis), )
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES (?, ?, ?, '', 'super-draft', ?, ?)",
(pid, pid, typ, vis, name),
) )
@@ -93,7 +100,7 @@ def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
with TestClient(app) as client: with TestClient(app) as client:
r = client.get("/rfc/human", follow_redirects=False) r = client.get("/rfc/human", follow_redirects=False)
assert r.status_code == 308 assert r.status_code == 308
assert r.headers["location"] == "/p/default/e/human" assert r.headers["location"] == "/p/default/c/default/e/human"
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea): def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
@@ -102,7 +109,7 @@ def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
with TestClient(app) as client: with TestClient(app) as client:
r = client.get("/rfc/human/pr/7", follow_redirects=False) r = client.get("/rfc/human/pr/7", follow_redirects=False)
assert r.status_code == 308 assert r.status_code == 308
assert r.headers["location"] == "/p/default/e/human/pr/7" assert r.headers["location"] == "/p/default/c/default/e/human/pr/7"
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea): def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
@@ -110,7 +117,7 @@ def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea)
with TestClient(app) as client: with TestClient(app) as client:
r = client.get("/proposals/42", follow_redirects=False) r = client.get("/proposals/42", follow_redirects=False)
assert r.status_code == 308 assert r.status_code == 308
assert r.headers["location"] == "/p/default/proposals/42" assert r.headers["location"] == "/p/default/c/default/proposals/42"
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea): def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
@@ -120,7 +127,7 @@ def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
_add_project("teamx", "Team X", "gated") _add_project("teamx", "Team X", "gated")
provision_user_row(user_id=5, login="mia", role="contributor") provision_user_row(user_id=5, login="mia", role="contributor")
db.conn().execute( db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) VALUES ('teamx', 5, 'project_viewer')" "INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('collection', 'teamx', 5, 'contributor')"
) )
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor") sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
# member sees the gated project in the deployment directory # member sees the gated project in the deployment directory
+1 -1
View File
@@ -33,7 +33,7 @@ def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea):
from app import db, entry as entry_mod from app import db, entry as entry_mod
app, fake = app_with_fake_gitea app, fake = app_with_fake_gitea
with TestClient(app) as client: with TestClient(app) as client:
db.conn().execute("UPDATE projects SET initial_state='active' WHERE id='default'") db.conn().execute("UPDATE collections SET initial_state='active' WHERE project_id='default'")
provision_user_row(user_id=1, login="ben", role="owner") provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
assert _propose(client).status_code == 200 assert _propose(client).status_code == 200
+8 -3
View File
@@ -21,12 +21,17 @@ def _fresh_config() -> Config:
def test_027_adds_project_type_and_initial_state(): def test_027_adds_project_type_and_initial_state():
# 027 added type/initial_state to `projects`; migration 029 (three-tier)
# moved those per-corpus fields *down* onto `collections`. After the full
# migration chain they live on the collection, not the project.
cfg = _fresh_config() cfg = _fresh_config()
db.run_migrations(cfg) db.run_migrations(cfg)
conn = db.connect(cfg.database_path) conn = db.connect(cfg.database_path)
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(projects)")} proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
assert "type" in cols and cols["type"]["dflt_value"] == "'document'" assert "type" not in proj_cols and "initial_state" not in proj_cols
assert "initial_state" in cols and cols["initial_state"]["dflt_value"] == "'super-draft'" coll_cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(collections)")}
assert "type" in coll_cols and coll_cols["type"]["dflt_value"] == "'document'"
assert "initial_state" in coll_cols and coll_cols["initial_state"]["dflt_value"] == "'super-draft'"
conn.close() conn.close()
@@ -1,95 +0,0 @@
"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates
project #2. Proves two projects can hold the same slug, that (project_id, slug)
is still unique within a project, that the rebuilt FK is composite + enforced,
and that the no-foreign-keys migration runner left no dangling references."""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
import pytest
from app import db
class _Cfg:
def __init__(self, path):
self.database_path = path
def _fresh_db():
d = tempfile.mkdtemp()
path = Path(d) / "t.db"
db.run_migrations(_Cfg(str(path)))
return db.connect(str(path))
def _seed_two_projects(conn):
for pid in ("default", "ecomm"):
conn.execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, 'document', ?, 'public', 'super-draft')",
(pid, pid.title(), pid + "-content"),
)
def test_same_slug_coexists_across_projects():
conn = _fresh_db()
_seed_two_projects(conn)
for pid in ("default", "ecomm"):
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', ?)",
(pid,),
)
rows = conn.execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id"
).fetchall()
assert [r["project_id"] for r in rows] == ["default", "ecomm"]
def test_slug_still_unique_within_a_project():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', 'default')"
)
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Dup', 'active', 'default')"
)
def test_rfc_collaborators_composite_fk_enforced():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', 'ecomm')"
)
# Matching (project_id, slug) — FK holds.
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
"VALUES ('intro', 1, 'contributor', 'ecomm')"
)
# Same slug but a project with no such entry — composite FK must reject.
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
"VALUES ('intro', 1, 'contributor', 'default')"
)
def test_stars_unique_now_scoped_by_project():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
# Same (user, slug) under two projects coexist; a duplicate within one rejects.
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
@@ -64,39 +64,55 @@ def _set_visibility(project_id: str, visibility: str) -> None:
) )
def _add_member(project_id: str, user_id: int, role: str) -> None: # §22 three-tier (§B.3): M2's three project roles collapse to {owner,
from app import db # contributor} in the unified `memberships` table at the project's default
# collection. The read-only `viewer` tier is deferred (folded into contributor
# for this pass), so the legacy role names map: admin→owner, contributor and
# viewer→contributor.
_ROLE_MAP = {
"project_admin": "owner",
"project_contributor": "contributor",
"project_viewer": "contributor",
}
def _add_member(project_id: str, user_id: int, role: str) -> None:
from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute( db.conn().execute(
"INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)", "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
(project_id, user_id, role), "VALUES ('collection', ?, ?, ?)",
(cid, user_id, _ROLE_MAP[role]),
) )
def _remove_member(project_id: str, user_id: int) -> None: def _remove_member(project_id: str, user_id: int) -> None:
from app import db from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute( db.conn().execute(
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?", "DELETE FROM memberships WHERE scope_type = 'collection' AND scope_id = ? AND user_id = ?",
(project_id, user_id), (cid, user_id),
) )
def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None: 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, """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 collection grain). The entry lands in the project's default collection
it explicitly for clarity.""" (id == project_id for the single 'default' project under test)."""
import json import json
from app import db from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute( db.conn().execute(
""" """
INSERT OR REPLACE INTO cached_rfcs INSERT OR REPLACE INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, project_id) (slug, title, state, owners_json, arbiters_json, tags_json, collection_id)
VALUES (?, ?, ?, ?, '[]', '[]', ?) VALUES (?, ?, ?, ?, '[]', '[]', ?)
""", """,
(slug, slug.capitalize(), state, json.dumps(owners or []), project_id), (slug, slug.capitalize(), state, json.dumps(owners or []), cid),
) )
@@ -154,18 +170,16 @@ def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
assert auth.can_read_project(owner, "default") is True assert auth.can_read_project(owner, "default") is True
assert auth.is_project_superuser(owner, "default") is True assert auth.is_project_superuser(owner, "default") is True
# project_viewer → read + discuss, but not contribute. # §22 three-tier (§B.3): the read-only viewer tier is deferred — the
_add_member("default", 1, "project_viewer") # smallest grant is `contributor`, which grants read + discuss +
# contribute across the subtree.
_add_member("default", 1, "project_contributor")
assert auth.can_read_project(contributor, "default") is True assert auth.can_read_project(contributor, "default") is True
assert auth.can_discuss_in_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.can_contribute_in_project(contributor, "default") is True
assert auth.is_project_superuser(contributor, "default") is False assert auth.is_project_superuser(contributor, "default") is False
# project_admin → superuser within the project. # project_admin → owner → superuser within the project.
_add_member("default", 1, "project_admin") _add_member("default", 1, "project_admin")
assert auth.is_project_superuser(contributor, "default") is True assert auth.is_project_superuser(contributor, "default") is True
@@ -270,7 +284,7 @@ def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
assert client.post("/api/rfcs/propose", json=body).status_code != 403 assert client.post("/api/rfcs/propose", json=body).status_code != 403
def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea): def test_gated_member_can_discuss_and_contribute(app_with_fake_gitea):
from app import auth from app import auth
app, _ = app_with_fake_gitea app, _ = app_with_fake_gitea
@@ -285,14 +299,11 @@ def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") 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 assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
# project_viewer: can discuss (200) but cannot contribute (resolver). # §22 three-tier (§B.3): a `contributor` member can both discuss and
_add_member("default", 2, "project_viewer") # contribute (the viewer-only read tier is deferred this pass).
_add_member("default", 2, "project_contributor")
r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}) r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
assert r.status_code == 200, r.text 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 assert auth.can_contribute_to_rfc(bob, "spec") is True
@@ -16,14 +16,18 @@ from test_propose_vertical import ( # noqa: F401
tmp_env, tmp_env,
) )
# The 19 tables migration 026 threads project_id onto (docs/design/ # §22 three-tier (migration 029): the entry-corpus grain is the collection, so
# multi-project-spec.md §5 amendment list). # the 13 tables migration 028 keyed by project_id re-key to collection_id. The
SLUG_TABLES = [ # remaining tables 026 tagged keep their denormalised project_id (project grain).
"cached_rfcs", "cached_branches", "cached_prs", "branch_visibility", COLLECTION_TABLES = [
"branch_contribute_grants", "stars", "threads", "changes", "pr_seen", "cached_rfcs", "cached_branches", "branch_visibility",
"branch_chat_seen", "watches", "notifications", "actions", "branch_contribute_grants", "stars", "pr_seen", "branch_chat_seen",
"pr_resolution_branches", "funder_consents", "rfc_invitations", "watches", "funder_consents", "rfc_invitations", "rfc_collaborators",
"rfc_collaborators", "proposed_use_cases", "contribution_requests", "proposed_use_cases", "contribution_requests",
]
PROJECT_TAG_TABLES = [
"cached_prs", "threads", "changes", "notifications", "actions",
"pr_resolution_branches",
] ]
@@ -45,25 +49,28 @@ def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
assert row["content_repo"] == "meta" assert row["content_repo"] == "meta"
def test_project_id_on_every_slug_table(app_with_fake_gitea): def test_grain_columns_on_every_slug_table(app_with_fake_gitea):
from app import db from app import db
app, _ = app_with_fake_gitea app, _ = app_with_fake_gitea
with TestClient(app): with TestClient(app):
for table in SLUG_TABLES: # The entry-corpus tables key on collection_id (NOT NULL, 'default').
cols = {r["name"]: r for r in db.conn().execute( for table in COLLECTION_TABLES:
f"PRAGMA table_info({table})" cols = {r["name"]: r for r in db.conn().execute(f"PRAGMA table_info({table})")}
)} assert "collection_id" in cols, f"{table} missing collection_id"
assert "project_id" in cols, f"{table} missing project_id" assert "project_id" not in cols, f"{table} should no longer have project_id"
col = cols["project_id"] col = cols["collection_id"]
# NOT NULL with the constant 'default' backfill default. assert col["notnull"] == 1, f"{table}.collection_id should be NOT NULL"
assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL" assert col["dflt_value"] == "'default'", f"{table}.collection_id default"
assert col["dflt_value"] == "'default'", f"{table}.project_id default" # The project-tag tables keep their denormalised project_id.
for table in PROJECT_TAG_TABLES:
cols = {r["name"] for r in db.conn().execute(f"PRAGMA table_info({table})")}
assert "project_id" in cols, f"{table} missing project_id tag"
def test_existing_row_backfills_to_default(app_with_fake_gitea): def test_existing_row_backfills_to_default(app_with_fake_gitea):
"""A row inserted the old way (no project_id) lands in the default """A row inserted the old way (no collection grain) lands in the default
project the trick that keeps every pre-multi-project INSERT working.""" collection the trick that keeps every pre-three-tier INSERT working."""
from app import db from app import db
app, _ = app_with_fake_gitea app, _ = app_with_fake_gitea
@@ -73,35 +80,36 @@ def test_existing_row_backfills_to_default(app_with_fake_gitea):
("human", "Human", "active"), ("human", "Human", "active"),
) )
got = db.conn().execute( got = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'human'" "SELECT collection_id FROM cached_rfcs WHERE slug = 'human'"
).fetchone()["project_id"] ).fetchone()["collection_id"]
assert got == "default" assert got == "default"
def test_project_members_table_shape(app_with_fake_gitea): def test_memberships_table_shape(app_with_fake_gitea):
from app import db from app import db
app, _ = app_with_fake_gitea app, _ = app_with_fake_gitea
with TestClient(app): with TestClient(app):
cols = {r["name"] for r in db.conn().execute( # §22 three-tier: project_members generalised into memberships.
"PRAGMA table_info(project_members)" assert db.conn().execute(
)} "SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'"
assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"} ).fetchone() is None
# The role CHECK rejects an unknown role. cols = {r["name"] for r in db.conn().execute("PRAGMA table_info(memberships)")}
assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols
db.conn().execute( db.conn().execute(
"INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')" "INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')"
) )
db.conn().execute( db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) " "INSERT INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('default', 1, 'project_admin')" "VALUES ('collection', 'default', 1, 'owner')"
) )
import sqlite3 import sqlite3
try: try:
db.conn().execute( db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) " "INSERT INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('default', 1, 'nonsense')" "VALUES ('collection', 'default', 1, 'nonsense')"
) )
assert False, "CHECK should reject an unknown project role" assert False, "CHECK should reject an unknown role"
except sqlite3.IntegrityError: except sqlite3.IntegrityError:
pass pass
+12 -4
View File
@@ -13,8 +13,12 @@ from test_propose_vertical import ( # noqa: F401
def _register_ecomm(fake): def _register_ecomm(fake):
from app import db from app import db
db.conn().execute( db.conn().execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " "INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
"VALUES ('ecomm', 'Ecomm', 'document', 'ecomm-content', 'public', 'super-draft')" "VALUES ('ecomm', 'Ecomm', 'ecomm-content', 'public')"
)
db.conn().execute(
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES ('ecomm', 'ecomm', 'document', '', 'super-draft', 'public', 'Ecomm')"
) )
fake._seed_repo("wiggleverse", "ecomm-content") fake._seed_repo("wiggleverse", "ecomm-content")
@@ -52,8 +56,12 @@ def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea):
from app import db from app import db
with TestClient(app) as client: with TestClient(app) as client:
db.conn().execute( db.conn().execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " "INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) "
"VALUES ('secret', 'Secret', 'document', 'secret-content', 'gated', 'super-draft')" "VALUES ('secret', 'Secret', 'secret-content', 'gated')"
)
db.conn().execute(
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES ('secret', 'secret', 'document', '', 'super-draft', 'gated', 'Secret')"
) )
provision_user_row(user_id=4, login="bob", role="contributor") provision_user_row(user_id=4, login="bob", role="contributor")
sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob", sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob",
+10 -3
View File
@@ -11,18 +11,25 @@ from test_propose_vertical import ( # noqa: F401
def _add_project(pid, name, vis="public"): def _add_project(pid, name, vis="public"):
# §22 three-tier: a project + its default collection (keyed by the project
# id in tests, so default_collection_id(pid) == pid).
from app import db from app import db
db.conn().execute( db.conn().execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " "INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
"VALUES (?, ?, 'document', ?, ?, 'super-draft')",
(pid, name, pid + "-content", vis), (pid, name, pid + "-content", vis),
) )
db.conn().execute(
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES (?, ?, 'document', '', 'super-draft', ?, ?)",
(pid, pid, vis, name),
)
def _add_rfc(slug, title, pid, state="active"): def _add_rfc(slug, title, pid, state="active"):
from app import db from app import db
# entries key by the project's default collection (id == pid in these tests)
db.conn().execute( db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES (?, ?, ?, ?)", "INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
(slug, title, state, pid), (slug, title, state, pid),
) )
+14 -6
View File
@@ -74,13 +74,20 @@ def test_parse_rejects_invalid(bad, msg):
def test_apply_upserts_projects_and_deployment(): def test_apply_upserts_projects_and_deployment():
_db() _db()
doc = registry.parse_registry(VALID) doc = registry.parse_registry(VALID)
registry.apply_registry(doc, registry_sha="regsha1") registry.apply_registry(doc, registry_sha="regsha1", default_id="default")
# §22 three-tier: the project carries the grouping-tier fields; the
# per-corpus type/initial_state live on its default collection.
prow = db.conn().execute( prow = db.conn().execute(
"SELECT name, type, content_repo, visibility, initial_state, registry_sha FROM projects WHERE id='default'" "SELECT name, content_repo, visibility, registry_sha FROM projects WHERE id='default'"
).fetchone() ).fetchone()
assert prow["name"] == "Open Human Model" assert prow["name"] == "Open Human Model"
assert prow["content_repo"] == "meta" assert prow["content_repo"] == "meta"
assert prow["registry_sha"] == "regsha1" assert prow["registry_sha"] == "regsha1"
crow = db.conn().execute(
"SELECT type, initial_state FROM collections WHERE id='default'"
).fetchone()
assert crow["type"] == "document"
assert crow["initial_state"] == "super-draft"
drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone() drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone()
assert drow["name"] == "Open Human Model" assert drow["name"] == "Open Human Model"
assert drow["tagline"] == "A model of human flourishing" assert drow["tagline"] == "A model of human flourishing"
@@ -88,11 +95,12 @@ def test_apply_upserts_projects_and_deployment():
def test_apply_rejects_type_change_on_existing_project(): def test_apply_rejects_type_change_on_existing_project():
_db() _db()
registry.apply_registry(registry.parse_registry(VALID), "s1") registry.apply_registry(registry.parse_registry(VALID), "s1", default_id="default")
changed = VALID.replace("type: document", "type: specification") changed = VALID.replace("type: document", "type: specification")
registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise registry.apply_registry(registry.parse_registry(changed), "s2", default_id="default") # skipped
t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"] # §22.4a immutable type — now enforced on the collection.
t = db.conn().execute("SELECT type FROM collections WHERE id='default'").fetchone()["type"]
assert t == "document" # immutable — unchanged assert t == "document" # immutable — unchanged
# The deployment row IS still advanced even though the project upsert was skipped. # The deployment row IS still advanced even though the type change was skipped.
drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone() drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone()
assert drow["registry_sha"] == "s2" assert drow["registry_sha"] == "s2"
+6 -2
View File
@@ -12,10 +12,14 @@ def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_git
app, _ = app_with_fake_gitea app, _ = app_with_fake_gitea
with TestClient(app): with TestClient(app):
prow = db.conn().execute( prow = db.conn().execute(
"SELECT content_repo, type, initial_state FROM projects WHERE id='default'" "SELECT content_repo FROM projects WHERE id='default'"
).fetchone() ).fetchone()
assert prow["content_repo"] == "meta" # from the registry, not META_REPO assert prow["content_repo"] == "meta" # from the registry, not META_REPO
assert prow["type"] == "document" # §22 three-tier: type now lives on the default collection.
crow = db.conn().execute(
"SELECT type FROM collections WHERE id='default'"
).fetchone()
assert crow["type"] == "document"
drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone() drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone()
assert drow["name"] # deployment name mirrored from the registry assert drow["name"] # deployment name mirrored from the registry
+26 -21
View File
@@ -1,6 +1,9 @@
"""§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured """§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured
DEFAULT_PROJECT_ID across every project-scoped table, with the composite FKs DEFAULT_PROJECT_ID. §22 three-tier (S1): the entry-corpus tables key on
kept intact and the stale 'default' projects row dropped. Idempotent.""" collection_id now, so the re-stamp renames the *project grain* the
`collections.project_id` link and the denormalised project_id tags while the
entries stay in their collection. The stale 'default' projects row is dropped,
the composite FKs stay intact, and it is idempotent."""
from __future__ import annotations from __future__ import annotations
import tempfile import tempfile
@@ -19,30 +22,31 @@ class _Cfg:
def _setup(monkeypatch, default_id="ohm"): def _setup(monkeypatch, default_id="ohm"):
path = str(Path(tempfile.mkdtemp()) / "t.db") path = str(Path(tempfile.mkdtemp()) / "t.db")
cfg = _Cfg(path, default_id) cfg = _Cfg(path, default_id)
db.run_migrations(cfg) db.run_migrations(cfg) # seeds the bootstrap 'default' project + its default collection
monkeypatch.setattr(db, "_CONN", db.connect(path)) monkeypatch.setattr(db, "_CONN", db.connect(path))
conn = db.conn() conn = db.conn()
# M1 bootstrap row + a registry-mirrored 'ohm' row coexist pre-restamp. # A registry-mirrored 'ohm' project coexists with the bootstrap pre-restamp.
conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) " conn.execute("INSERT OR IGNORE INTO projects (id,name,content_repo,visibility) "
"VALUES ('default','Bootstrap','document','ohm-content','public','super-draft')") "VALUES ('ohm','Open Human Model','ohm-content','public')")
conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) "
"VALUES ('ohm','Open Human Model','document','ohm-content','public','super-draft')")
conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')") conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')")
# default-stamped data with a composite-FK child # Entry data lives in the default collection (id='default'); the entry grain
conn.execute("INSERT INTO cached_rfcs (slug,title,state,project_id) VALUES ('human','Human','active','default')") # is the collection and does not move on a re-stamp.
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,project_id) " conn.execute("INSERT INTO cached_rfcs (slug,title,state,collection_id) VALUES ('human','Human','active','default')")
conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,collection_id) "
"VALUES ('human',1,'contributor','default')") "VALUES ('human',1,'contributor','default')")
conn.execute("INSERT INTO stars (user_id,rfc_slug,project_id) VALUES (1,'human','default')") conn.execute("INSERT INTO stars (user_id,rfc_slug,collection_id) VALUES (1,'human','default')")
return cfg, conn return cfg, conn
def test_restamp_moves_data_and_drops_bootstrap_row(monkeypatch): def test_restamp_moves_project_grain_and_drops_bootstrap_row(monkeypatch):
cfg, conn = _setup(monkeypatch, default_id="ohm") cfg, conn = _setup(monkeypatch, default_id="ohm")
projects.restamp_default_project(cfg) projects.restamp_default_project(cfg)
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='default'").fetchone()["c"] == 0 # The project grain (the collection's parent link) re-stamps to 'ohm'.
assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "ohm" assert conn.execute("SELECT COUNT(*) c FROM collections WHERE project_id='default'").fetchone()["c"] == 0
assert conn.execute("SELECT project_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm" assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
assert conn.execute("SELECT project_id FROM stars WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm" # Entries stay in their collection — the collection_id is unchanged.
assert conn.execute("SELECT collection_id FROM cached_rfcs WHERE slug='human'").fetchone()["collection_id"] == "default"
assert conn.execute("SELECT collection_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["collection_id"] == "default"
# stale bootstrap projects row removed; 'ohm' remains # stale bootstrap projects row removed; 'ohm' remains
assert conn.execute("SELECT 1 FROM projects WHERE id='default'").fetchone() is None assert conn.execute("SELECT 1 FROM projects WHERE id='default'").fetchone() is None
assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None assert conn.execute("SELECT 1 FROM projects WHERE id='ohm'").fetchone() is not None
@@ -53,12 +57,13 @@ def test_restamp_moves_data_and_drops_bootstrap_row(monkeypatch):
def test_restamp_is_idempotent(monkeypatch): def test_restamp_is_idempotent(monkeypatch):
cfg, conn = _setup(monkeypatch, default_id="ohm") cfg, conn = _setup(monkeypatch, default_id="ohm")
projects.restamp_default_project(cfg) projects.restamp_default_project(cfg)
projects.restamp_default_project(cfg) # second call: no rows left → no-op projects.restamp_default_project(cfg) # second call: no bootstrap rows left → no-op
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='ohm'").fetchone()["c"] == 1 assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "ohm"
assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE collection_id='default'").fetchone()["c"] == 1
def test_restamp_noop_when_default_id_unchanged(monkeypatch): def test_restamp_noop_when_default_id_unchanged(monkeypatch):
cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default' cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default'
projects.restamp_default_project(cfg) projects.restamp_default_project(cfg)
# nothing renamed; bootstrap data + row still present # nothing renamed; the default collection still belongs to the bootstrap project
assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "default" assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "default"
@@ -0,0 +1,78 @@
"""@S1 acceptance — the collection grain exists (invisible default) and N=1 is
unchanged.
Part C scenarios C3.7 (single-collection project skips the directory) and C3.8
(single-project deployment skips the directory) are the client-side redirect
contract asserted in the frontend; this module asserts the backend N=1
invariants behind the slice: every entry keys on a real collection_id, the
shipped project-scoped serving still resolves through the default collection,
and the legacy /rfc/<slug> URL 308-redirects through /c/<default>/.
Binding: docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _seed_entry(slug, title, collection_id="default", state="active"):
from app import db
db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
(slug, title, state, collection_id),
)
def test_s1_migration_seeds_one_default_collection_for_the_default_project(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
row = db.conn().execute(
"SELECT id FROM collections WHERE project_id = 'default'"
).fetchall()
assert len(row) == 1
assert row[0]["id"] == "default"
def test_s1_entry_served_under_default_collection(app_with_fake_gitea):
"""N=1 unchanged: an entry is keyed by collection_id under the hood and the
shipped project-scoped serving endpoint still resolves it."""
from app import db
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_entry("human", "Human")
# the row carries a real collection grain (the default collection)
cid = db.conn().execute(
"SELECT collection_id FROM cached_rfcs WHERE slug='human'"
).fetchone()["collection_id"]
assert cid == "default"
# project-scoped serving (collection = default) still returns it
r = client.get("/api/projects/default/rfcs/human")
assert r.status_code == 200, r.text
assert r.json()["slug"] == "human"
# and it appears in the project catalog
slugs = [i["slug"] for i in client.get("/api/projects/default/rfcs").json()["items"]]
assert "human" in slugs
def test_s1_legacy_rfc_url_redirects_through_collection(app_with_fake_gitea):
"""The shipped /rfc/<slug> now 308s through the default collection segment."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/rfc/human", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/c/default/e/human"
def test_s1_deployment_reports_single_project(app_with_fake_gitea):
"""C3.8 precondition: the N=1 deployment reports exactly one visible project
and its default id (the frontend uses this to skip the directory)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
body = client.get("/api/deployment").json()
assert body["default_project_id"] == "default"
assert [p["id"] for p in body["projects"]] == ["default"]