diff --git a/backend/app/api.py b/backend/app/api.py index 0318b48..0d5cd2c 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -29,6 +29,7 @@ from . import ( api_notifications, api_prs, auth, + collections as collections_mod, projects as projects_mod, db, device_trust as device_trust_mod, @@ -637,13 +638,13 @@ def make_router( unreviewed_clause = " AND unreviewed = 1 AND state = 'active'" rows = db.conn().execute( f""" - SELECT slug, title, state, rfc_id, repo, - owners_json, arbiters_json, tags_json, - last_main_commit_at, last_entry_commit_at, updated_at - FROM cached_rfcs - WHERE state IN ('super-draft', 'active') - AND project_id IN ({placeholders}){unreviewed_clause} - ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC + SELECT r.slug, r.title, r.state, r.rfc_id, r.repo, + r.owners_json, r.arbiters_json, r.tags_json, + r.last_main_commit_at, r.last_entry_commit_at, r.updated_at + FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id + WHERE r.state IN ('super-draft', 'active') + AND c.project_id IN ({placeholders}){unreviewed_clause} + ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC """, params, ).fetchall() @@ -685,8 +686,8 @@ def make_router( 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"]) + # 404 to non-members. Recover the project via the entry's collection. + auth.require_project_readable(viewer, auth.project_of_rfc(slug)) # §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. @@ -723,6 +724,8 @@ def make_router( viewer = auth.current_user(request) # §22.5 read gate: a gated project's catalog 404s to a non-member. auth.require_project_readable(viewer, project_id) + # §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 unreviewed_clause = "" 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 FROM cached_rfcs 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 """, - (project_id,), + (collection_id,), ).fetchall() starred = set() if viewer_id is not None: starred = { r["rfc_slug"] for r in db.conn().execute( - "SELECT rfc_slug FROM stars WHERE user_id = ? AND project_id = ?", - (viewer_id, project_id), + "SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?", + (viewer_id, collection_id), ) } items = [ @@ -770,9 +773,10 @@ def make_router( async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) auth.require_project_readable(viewer, project_id) + collection_id = collections_mod.default_collection_id(project_id) row = db.conn().execute( - "SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?", - (project_id, slug), + "SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?", + (collection_id, slug), ).fetchone() if row is None: raise HTTPException(404, "Not found") @@ -782,10 +786,10 @@ def make_router( uc = db.conn().execute( """ 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 """, - (slug, project_id), + (slug, collection_id), ).fetchone() payload["proposed_use_case"] = uc["use_case"] if uc else None return payload @@ -802,9 +806,10 @@ def make_router( auth.require_project_readable(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") + collection_id = collections_mod.default_collection_id(project_id) row = db.conn().execute( - "SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND project_id = ?", - (slug, project_id), + "SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?", + (slug, collection_id), ).fetchone() if row is None: raise HTTPException(404, "Not found") @@ -965,8 +970,9 @@ def make_router( # We re-check atomically here even though the client also checks # on every keystroke, since a concurrent submission could land # between dialog-open and submit. + collection_id = collections_mod.default_collection_id(project_id) 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() if clash: raise HTTPException(409, f"Slug `{slug}` is already taken") @@ -1041,11 +1047,11 @@ def make_router( if use_case: 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', ?, ?, ?, ?) - 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( "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]: user = auth.require_contributor(request) rfc = db.conn().execute( - "SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,) + "SELECT 1 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"]) + auth.require_project_readable(user, auth.project_of_rfc(slug)) # §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. diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index 8e54c48..74b18c8 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -742,7 +742,7 @@ def make_router( """ INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) 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, 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) 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, seen_at = excluded.seen_at """, @@ -1092,7 +1092,7 @@ def make_router( # ------------------------------------------------------------------ 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: raise HTTPException(404, "RFC not found") # §22.5 visibility gate (subtractive, §22.7): a gated project's entries diff --git a/backend/app/api_contributions.py b/backend/app/api_contributions.py index 5a4c0b8..e6f36c0 100644 --- a/backend/app/api_contributions.py +++ b/backend/app/api_contributions.py @@ -62,7 +62,7 @@ def _require_super_draft(slug: str, viewer): 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, 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,), ).fetchone() if row is None: @@ -108,7 +108,7 @@ 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, 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,), ).fetchone() if row is None: diff --git a/backend/app/api_deployment.py b/backend/app/api_deployment.py index 1406619..2b7b1a2 100644 --- a/backend/app/api_deployment.py +++ b/backend/app/api_deployment.py @@ -18,7 +18,7 @@ from typing import Any from fastapi import APIRouter, HTTPException, Request 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 @@ -33,12 +33,21 @@ def make_router(config: Config) -> APIRouter: ).fetchone() # §22.5: enumerate only public + (member-)gated; unlisted is never listed. 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( - "SELECT id, name, type, visibility FROM projects " + "SELECT id, name, visibility FROM projects " "WHERE visibility != 'unlisted' ORDER BY name" ).fetchall() 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 if r["id"] in visible ] @@ -60,8 +69,7 @@ def make_router(config: Config) -> APIRouter: # an unknown id). unlisted is readable by direct id. auth.require_project_readable(viewer, project_id) row = db.conn().execute( - "SELECT id, name, type, visibility, initial_state, config_json " - "FROM projects WHERE id = ?", + "SELECT id, name, visibility, config_json FROM projects WHERE id = ?", (project_id,), ).fetchone() if row is None: @@ -71,13 +79,16 @@ def make_router(config: Config) -> APIRouter: except (ValueError, TypeError): cfg = {} 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 { "id": row["id"], "name": row["name"], "tagline": (dep["tagline"] if dep else "") or "", - "type": row["type"], + "type": collections_mod.collection_type(cid), "visibility": row["visibility"], - "initial_state": row["initial_state"], + "initial_state": collections_mod.collection_initial_state(cid), "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. # nginx routes /rfc/ and /proposals/ to the backend so these are reached # before the SPA's index.html fallback. + # §22 three-tier (S1): the canonical entry route now carries the collection + # segment /p//c//…. The legacy roots redirect through + # the default project's default collection. @router.get("/rfc/{slug}") async def redirect_old_rfc(slug: str) -> RedirectResponse: 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}") async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse: default_id = projects_mod.resolved_default_id(config) + cid = collections_mod.default_collection_id(default_id) 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}") async def redirect_old_proposal(pr_number: int) -> RedirectResponse: 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 diff --git a/backend/app/api_discussion.py b/backend/app/api_discussion.py index 9173b44..be57845 100644 --- a/backend/app/api_discussion.py +++ b/backend/app/api_discussion.py @@ -252,7 +252,7 @@ def _require_rfc_readable(slug: str, viewer): 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,) + "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: raise HTTPException(404, "RFC not found") diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index 6c61adb..b620aa3 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -574,7 +574,7 @@ def make_router( # ------------------------------------------------------------------- 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: raise HTTPException(404, "RFC not found") # §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members. @@ -584,7 +584,7 @@ def make_router( return row 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: raise HTTPException(404, "RFC not found") if row["state"] not in ("super-draft", "active"): @@ -592,7 +592,7 @@ def make_router( return row 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: raise HTTPException(404, "RFC not found") if row["state"] != "retired": diff --git a/backend/app/api_invitations.py b/backend/app/api_invitations.py index b8440b1..70119e0 100644 --- a/backend/app/api_invitations.py +++ b/backend/app/api_invitations.py @@ -380,7 +380,7 @@ def _require_rfc(slug: str, viewer): visibility gate is subtractive: a gated project's entries 404 to non-members (§22.7).""" 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() if row is None: raise HTTPException(404, "RFC not found") diff --git a/backend/app/api_notifications.py b/backend/app/api_notifications.py index a76bf8e..89b00fe 100644 --- a/backend/app/api_notifications.py +++ b/backend/app/api_notifications.py @@ -213,7 +213,7 @@ 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 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: raise HTTPException(404, "RFC not found") # §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) 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, set_by = 'explicit', set_at = excluded.set_at diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index c4157c6..69a6678 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -153,7 +153,7 @@ def make_router( """ INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode) 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), ) @@ -189,7 +189,7 @@ def make_router( """ INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case) 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), ) @@ -398,7 +398,7 @@ def make_router( INSERT INTO pr_seen (user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at) 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_message_id = excluded.last_seen_message_id, seen_at = excluded.seen_at @@ -662,7 +662,7 @@ def make_router( # ------------------------------------------------------------------ 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: raise HTTPException(404, "RFC not found") # §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always diff --git a/backend/app/auth.py b/backend/app/auth.py index 8461171..98a6978 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -337,24 +337,41 @@ def project_visibility(project_id: str) -> str: 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).""" + """The user's *explicit* §22.6 membership role at this project, or None. + + §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: return None + from . import collections as collections_mod + cid = collections_mod.default_collection_id(project_id) row = db.conn().execute( - "SELECT role FROM project_members WHERE project_id = ? AND user_id = ?", - (project_id, user.user_id), + "SELECT role FROM memberships " + "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() - 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: - """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.""" + """The project an RFC belongs to, via its collection + (`cached_rfcs.collection_id` -> `collections.project_id`, §22 three-tier). + Falls back to the default project when the slug isn't cached — the same N=1 + default migration 026 backfills.""" 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() if row is None: return DEFAULT_PROJECT_ID diff --git a/backend/app/cache.py b/backend/app/cache.py index 4af2ab2..538c313 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -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: + # §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: files = await gitea.list_dir(org, repo, "rfcs", ref="main") 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"]) continue 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 # 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 = { row["slug"] 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: 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 # (inherit operator universe) and '[]' for the explicit opt-out. 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, graduated_at, graduated_by, owners_json, arbiters_json, tags_json, 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) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now')) - ON CONFLICT(project_id, slug) DO UPDATE SET + ON CONFLICT(collection_id, slug) DO UPDATE SET title = excluded.title, state = excluded.state, 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, entry.reviewed_at, 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) 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, state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, 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) 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, state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END, 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) 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, last_commit_at = excluded.last_commit_at """, diff --git a/backend/app/collections.py b/backend/app/collections.py new file mode 100644 index 0000000..d767b32 --- /dev/null +++ b/backend/app/collections.py @@ -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" diff --git a/backend/app/funder.py b/backend/app/funder.py index c4793d5..4a694d1 100644 --- a/backend/app/funder.py +++ b/backend/app/funder.py @@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None: db.conn().execute( """ 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), ) diff --git a/backend/app/projects.py b/backend/app/projects.py index c76609d..e6d56d8 100644 --- a/backend/app/projects.py +++ b/backend/app/projects.py @@ -43,8 +43,11 @@ def restamp_default_project(config: Config) -> None: if target == DEFAULT_PROJECT_ID: return 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( - "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() stale_proj = conn.execute( "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: - """§22.4b landing state for new entries in a project. Defaults to - 'super-draft' for an unknown/unset row (the safe, today's-flow default).""" - row = db.conn().execute( - "SELECT initial_state FROM projects WHERE id = ?", (project_id,) - ).fetchone() - if row is None or not row["initial_state"]: - return "super-draft" - return row["initial_state"] + """§22.4b landing state for new entries in a project's default collection + (the per-corpus field moved down to the collection in migration 029). + Defaults to 'super-draft' for an unknown/unset row (today's-flow default).""" + from . import collections as collections_mod + return collections_mod.collection_initial_state( + collections_mod.default_collection_id(project_id) + ) diff --git a/backend/app/registry.py b/backend/app/registry.py index a04343f..38981b2 100644 --- a/backend/app/registry.py +++ b/backend/app/registry.py @@ -114,44 +114,74 @@ def parse_registry(text: str) -> RegistryDoc: ) -def apply_registry(doc: RegistryDoc, registry_sha: str) -> None: - """Upsert the parsed registry into projects + deployment. Idempotent. +def _default_collection_id(project_id: str, default_id: str) -> str: + """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 - place (archival is out of scope for M3; they simply stop refreshing). + +def apply_registry(doc: RegistryDoc, registry_sha: str, default_id: str) -> None: + """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: for e in doc.projects: + cid = _default_collection_id(e.id, default_id) existing = conn.execute( - "SELECT type FROM projects WHERE id = ?", (e.id,) + "SELECT type FROM collections WHERE id = ?", (cid,) ).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( - "registry: refusing immutable type change on project %s (%s -> %s)", - e.id, existing["type"], e.type, + "registry: refusing immutable type change on collection %s (%s -> %s)", + cid, existing["type"], e.type, ) - continue + # The project (grouping tier) always refreshes. conn.execute( """ INSERT INTO projects - (id, name, type, content_repo, visibility, initial_state, - config_json, registry_sha, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, datetime('now')) + (id, name, content_repo, visibility, config_json, registry_sha, updated_at) + VALUES (?, ?, ?, ?, ?, ?, datetime('now')) ON CONFLICT(id) DO UPDATE SET name = excluded.name, - type = excluded.type, content_repo = excluded.content_repo, visibility = excluded.visibility, - initial_state = excluded.initial_state, config_json = excluded.config_json, registry_sha = excluded.registry_sha, updated_at = datetime('now') """, - ( - e.id, e.name, e.type, e.content_repo, e.visibility, - e.initial_state, json.dumps(e.config), registry_sha, - ), + (e.id, e.name, e.content_repo, e.visibility, 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( """ @@ -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. sha = item.get("last_commit_sha") or item.get("sha") or "" 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) diff --git a/backend/tests/test_api_deployment.py b/backend/tests/test_api_deployment.py index e0ea774..510e8f6 100644 --- a/backend/tests/test_api_deployment.py +++ b/backend/tests/test_api_deployment.py @@ -9,11 +9,18 @@ from test_propose_vertical import ( # noqa: F401 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 db.conn().execute( - "INSERT OR REPLACE INTO projects (id, name, type, content_repo, visibility, initial_state) " - "VALUES (?, ?, ?, ?, ?, 'super-draft')", - (pid, name, typ, pid, vis), + "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)", + (pid, name, 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: r = client.get("/rfc/human", follow_redirects=False) 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): @@ -102,7 +109,7 @@ def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea): with TestClient(app) as client: r = client.get("/rfc/human/pr/7", follow_redirects=False) 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): @@ -110,7 +117,7 @@ def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea) with TestClient(app) as client: r = client.get("/proposals/42", follow_redirects=False) 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): @@ -120,7 +127,7 @@ def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea): _add_project("teamx", "Team X", "gated") provision_user_row(user_id=5, login="mia", role="contributor") 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") # member sees the gated project in the deployment directory diff --git a/backend/tests/test_initial_state_landing.py b/backend/tests/test_initial_state_landing.py index dd0931c..ad47e62 100644 --- a/backend/tests/test_initial_state_landing.py +++ b/backend/tests/test_initial_state_landing.py @@ -33,7 +33,7 @@ def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea): from app import db, entry as entry_mod app, fake = app_with_fake_gitea 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") sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner") assert _propose(client).status_code == 200 diff --git a/backend/tests/test_migration_027.py b/backend/tests/test_migration_027.py index 1c47ca7..6e90a50 100644 --- a/backend/tests/test_migration_027.py +++ b/backend/tests/test_migration_027.py @@ -21,12 +21,17 @@ def _fresh_config() -> Config: 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() db.run_migrations(cfg) conn = db.connect(cfg.database_path) - cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(projects)")} - assert "type" in cols and cols["type"]["dflt_value"] == "'document'" - assert "initial_state" in cols and cols["initial_state"]["dflt_value"] == "'super-draft'" + proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")} + assert "type" not in proj_cols and "initial_state" not in proj_cols + 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() diff --git a/backend/tests/test_migration_028_project_scoped_keys.py b/backend/tests/test_migration_028_project_scoped_keys.py deleted file mode 100644 index a5a7174..0000000 --- a/backend/tests/test_migration_028_project_scoped_keys.py +++ /dev/null @@ -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')") diff --git a/backend/tests/test_multi_project_authz_vertical.py b/backend/tests/test_multi_project_authz_vertical.py index 890f496..6631192 100644 --- a/backend/tests/test_multi_project_authz_vertical.py +++ b/backend/tests/test_multi_project_authz_vertical.py @@ -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: - from app import db +# §22 three-tier (§B.3): M2's three project roles collapse to {owner, +# 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( - "INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)", - (project_id, user_id, role), + "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('collection', ?, ?, ?)", + (cid, user_id, _ROLE_MAP[role]), ) 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( - "DELETE FROM project_members WHERE project_id = ? AND user_id = ?", - (project_id, user_id), + "DELETE FROM memberships WHERE scope_type = 'collection' AND scope_id = ? AND user_id = ?", + (cid, 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.""" + collection grain). The entry lands in the project's default collection + (id == project_id for the single 'default' project under test).""" 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( """ 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 (?, ?, ?, ?, '[]', '[]', ?) """, - (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.is_project_superuser(owner, "default") is True - # project_viewer → read + discuss, but not contribute. - _add_member("default", 1, "project_viewer") + # §22 three-tier (§B.3): the read-only viewer tier is deferred — the + # 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_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. + # project_admin → owner → superuser within the project. _add_member("default", 1, "project_admin") 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 -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 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") 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") + # §22 three-tier (§B.3): a `contributor` member can both discuss and + # 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"}) 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 diff --git a/backend/tests/test_multi_project_spine_vertical.py b/backend/tests/test_multi_project_spine_vertical.py index 35ce1dd..2ab404d 100644 --- a/backend/tests/test_multi_project_spine_vertical.py +++ b/backend/tests/test_multi_project_spine_vertical.py @@ -16,14 +16,18 @@ from test_propose_vertical import ( # noqa: F401 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", +# §22 three-tier (migration 029): the entry-corpus grain is the collection, so +# the 13 tables migration 028 keyed by project_id re-key to collection_id. The +# remaining tables 026 tagged keep their denormalised project_id (project grain). +COLLECTION_TABLES = [ + "cached_rfcs", "cached_branches", "branch_visibility", + "branch_contribute_grants", "stars", "pr_seen", "branch_chat_seen", + "watches", "funder_consents", "rfc_invitations", "rfc_collaborators", + "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" -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 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" + # The entry-corpus tables key on collection_id (NOT NULL, 'default'). + for table in COLLECTION_TABLES: + 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" not in cols, f"{table} should no longer have project_id" + col = cols["collection_id"] + assert col["notnull"] == 1, f"{table}.collection_id should be NOT NULL" + assert col["dflt_value"] == "'default'", f"{table}.collection_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): - """A row inserted the old way (no project_id) lands in the default - project — the trick that keeps every pre-multi-project INSERT working.""" + """A row inserted the old way (no collection grain) lands in the default + collection — the trick that keeps every pre-three-tier INSERT working.""" from app import db app, _ = app_with_fake_gitea @@ -73,35 +80,36 @@ def test_existing_row_backfills_to_default(app_with_fake_gitea): ("human", "Human", "active"), ) got = db.conn().execute( - "SELECT project_id FROM cached_rfcs WHERE slug = 'human'" - ).fetchone()["project_id"] + "SELECT collection_id FROM cached_rfcs WHERE slug = 'human'" + ).fetchone()["collection_id"] 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 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. + # §22 three-tier: project_members generalised into memberships. + assert db.conn().execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'" + ).fetchone() is None + 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( "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')" + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('collection', 'default', 1, 'owner')" ) import sqlite3 try: db.conn().execute( - "INSERT INTO project_members (project_id, user_id, role) " - "VALUES ('default', 1, 'nonsense')" + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "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: pass diff --git a/backend/tests/test_project_scoped_propose.py b/backend/tests/test_project_scoped_propose.py index 251984c..7cabaf7 100644 --- a/backend/tests/test_project_scoped_propose.py +++ b/backend/tests/test_project_scoped_propose.py @@ -13,8 +13,12 @@ from test_propose_vertical import ( # noqa: F401 def _register_ecomm(fake): from app import db db.conn().execute( - "INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " - "VALUES ('ecomm', 'Ecomm', 'document', 'ecomm-content', 'public', 'super-draft')" + "INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) " + "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") @@ -52,8 +56,12 @@ def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea): from app import db with TestClient(app) as client: db.conn().execute( - "INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " - "VALUES ('secret', 'Secret', 'document', 'secret-content', 'gated', 'super-draft')" + "INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) " + "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") sign_in_as(client, user_id=4, gitea_login="bob", display_name="Bob", diff --git a/backend/tests/test_project_scoped_serving.py b/backend/tests/test_project_scoped_serving.py index a455ed1..785daf4 100644 --- a/backend/tests/test_project_scoped_serving.py +++ b/backend/tests/test_project_scoped_serving.py @@ -11,18 +11,25 @@ from test_propose_vertical import ( # noqa: F401 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 db.conn().execute( - "INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) " - "VALUES (?, ?, 'document', ?, ?, 'super-draft')", + "INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)", (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"): from app import db + # entries key by the project's default collection (id == pid in these tests) 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), ) diff --git a/backend/tests/test_registry.py b/backend/tests/test_registry.py index 68e0022..c4a39cb 100644 --- a/backend/tests/test_registry.py +++ b/backend/tests/test_registry.py @@ -74,13 +74,20 @@ def test_parse_rejects_invalid(bad, msg): def test_apply_upserts_projects_and_deployment(): _db() 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( - "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() assert prow["name"] == "Open Human Model" assert prow["content_repo"] == "meta" 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() assert drow["name"] == "Open Human Model" 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(): _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") - registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise - t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"] + registry.apply_registry(registry.parse_registry(changed), "s2", default_id="default") # skipped + # §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 - # 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() assert drow["registry_sha"] == "s2" diff --git a/backend/tests/test_registry_wiring.py b/backend/tests/test_registry_wiring.py index dedb7cd..4e15ddd 100644 --- a/backend/tests/test_registry_wiring.py +++ b/backend/tests/test_registry_wiring.py @@ -12,10 +12,14 @@ def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_git app, _ = app_with_fake_gitea with TestClient(app): prow = db.conn().execute( - "SELECT content_repo, type, initial_state FROM projects WHERE id='default'" + "SELECT content_repo FROM projects WHERE id='default'" ).fetchone() 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() assert drow["name"] # deployment name mirrored from the registry diff --git a/backend/tests/test_restamp_default_project.py b/backend/tests/test_restamp_default_project.py index 9b3c69c..c955097 100644 --- a/backend/tests/test_restamp_default_project.py +++ b/backend/tests/test_restamp_default_project.py @@ -1,6 +1,9 @@ """§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured -DEFAULT_PROJECT_ID across every project-scoped table, with the composite FKs -kept intact and the stale 'default' projects row dropped. Idempotent.""" +DEFAULT_PROJECT_ID. §22 three-tier (S1): the entry-corpus tables key on +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 import tempfile @@ -19,30 +22,31 @@ class _Cfg: def _setup(monkeypatch, default_id="ohm"): path = str(Path(tempfile.mkdtemp()) / "t.db") 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)) conn = db.conn() - # M1 bootstrap row + a registry-mirrored 'ohm' row coexist pre-restamp. - conn.execute("INSERT OR IGNORE INTO projects (id,name,type,content_repo,visibility,initial_state) " - "VALUES ('default','Bootstrap','document','ohm-content','public','super-draft')") - 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')") + # A registry-mirrored 'ohm' project coexists with the bootstrap pre-restamp. + conn.execute("INSERT OR IGNORE INTO projects (id,name,content_repo,visibility) " + "VALUES ('ohm','Open Human Model','ohm-content','public')") conn.execute("INSERT INTO users (id,gitea_login,display_name,role) VALUES (1,'a','A','contributor')") - # default-stamped data with a composite-FK child - conn.execute("INSERT INTO cached_rfcs (slug,title,state,project_id) VALUES ('human','Human','active','default')") - conn.execute("INSERT INTO rfc_collaborators (rfc_slug,user_id,role_in_rfc,project_id) " + # Entry data lives in the default collection (id='default'); the entry grain + # is the collection and does not move on a re-stamp. + 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')") - 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 -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") projects.restamp_default_project(cfg) - assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='default'").fetchone()["c"] == 0 - assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "ohm" - assert conn.execute("SELECT project_id FROM rfc_collaborators WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm" - assert conn.execute("SELECT project_id FROM stars WHERE rfc_slug='human'").fetchone()["project_id"] == "ohm" + # The project grain (the collection's parent link) re-stamps to 'ohm'. + assert conn.execute("SELECT COUNT(*) c FROM collections WHERE project_id='default'").fetchone()["c"] == 0 + assert conn.execute("SELECT project_id FROM collections WHERE id='default'").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 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 @@ -53,12 +57,13 @@ def test_restamp_moves_data_and_drops_bootstrap_row(monkeypatch): def test_restamp_is_idempotent(monkeypatch): cfg, conn = _setup(monkeypatch, default_id="ohm") projects.restamp_default_project(cfg) - projects.restamp_default_project(cfg) # second call: no rows left → no-op - assert conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE project_id='ohm'").fetchone()["c"] == 1 + projects.restamp_default_project(cfg) # second call: no bootstrap rows left → no-op + 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): cfg, conn = _setup(monkeypatch, default_id="") # resolves to 'default' projects.restamp_default_project(cfg) - # nothing renamed; bootstrap data + row still present - assert conn.execute("SELECT project_id FROM cached_rfcs WHERE slug='human'").fetchone()["project_id"] == "default" + # nothing renamed; the default collection still belongs to the bootstrap project + assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "default" diff --git a/backend/tests/test_s1_collection_grain_vertical.py b/backend/tests/test_s1_collection_grain_vertical.py new file mode 100644 index 0000000..24a5936 --- /dev/null +++ b/backend/tests/test_s1_collection_grain_vertical.py @@ -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/ URL 308-redirects through /c//. + +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/ 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"]