§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_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.
+3 -3
View File
@@ -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
+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
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:
+27 -10
View File
@@ -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/<project>/c/<collection>/…. 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
+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`
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")
+3 -3
View File
@@ -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":
+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
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")
+2 -2
View File
@@ -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
+4 -4
View File
@@ -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
+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:
"""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
+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:
# §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
""",
+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(
"""
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),
)
+11 -9
View File
@@ -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)
)
+51 -20
View File
@@ -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)
+14 -7
View File
@@ -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
+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
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
+8 -3
View File
@@ -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()
@@ -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:
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
@@ -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
+12 -4
View File
@@ -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",
+10 -3
View File
@@ -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),
)
+14 -6
View File
@@ -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"
+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
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
+26 -21
View File
@@ -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"
@@ -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"]