diff --git a/CHANGELOG.md b/CHANGELOG.md index 46325b2..b56ae4b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,223 @@ skip versions are the composition of each intervening adjacent release's steps in order — no A-to-B path is pre-computed beyond that. +## 0.42.0 — 2026-06-05 + +**Minor (breaking — upgrade steps below) — §22 three-tier refactor, slice S3: +*scope-role enforcement + collection-grain visibility.* Authorization is now +resolved by the four-layer most-permissive union of §B.2 — global → project → +collection → per-entry — over the unified `{owner, contributor}` roles, with the +§22.5 visibility gate enforced at the **collection** grain. A collection can be +"hidden from public existence" (`gated`): invisible to anonymous viewers and +omitted from the project directory, yet readable and listed for any contributor +holding a scope role that reaches it (collection / project / global). A +collection's visibility may be set only as strict or stricter than its project's. +Completes acceptance scenarios `@S3` (C1.1–C1.8: role usage, inheritance, the +most-permissive union, and no-negative-override).** + +See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md) +(Part B roles, Part C.1 scenarios, Part E slice S3). The invitation UI and +role-keyed empty states remain S4; grants in S3 are applied administratively +(via the `memberships` table / an Owner-authorized create surface). + +Added: + +- **Four-layer scope-role resolver** — `effective_scope_role(user, collection)` + folds a deployment owner/admin (global Owner), an explicit global-scope grant, + a project-scope grant, and a collection-scope grant into the most-permissive + unified role over a collection. Owner outranks RFC Contributor; there is no + negative override (a child scope can never subtract a parent grant). +- **Global-scope grants** — migration 030 extends `memberships.scope_type` to + `{global, project, collection}`. A "global RFC Contributor" (writes in every + collection of every project, distinct from a deployment owner) is a + `scope_type='global'` row (sentinel `scope_id='*'`). +- **Collection-grain visibility enforcement** — `can_read_collection` / + `require_collection_readable` gate the collection-scoped read, entry, and + propose routes; the project collection-directory (`GET + /api/projects//collections`) is now viewer-aware (a hidden/gated collection + is listed only for a scope-role holder; `unlisted` stays omitted from + enumeration). The effective read gate is the stricter of the project's and the + collection's visibility. +- **Collection visibility strictness** — a collection may narrow but never widen + its project's visibility (`public` < `unlisted` < `gated`). Enforced at + create-collection (422 on a looser request) and clamped at the registry mirror. +- **create-collection authority widened (§B.1)** — `POST + /api/projects//collections` now admits a project-scope or global-scope + grant holder (Owner **or** RFC Contributor — the project-level "create a + collection" affordance), not only a deployment owner/admin. A collection-scope + grant cannot create sibling collections. + +Changed (breaking): + +- **Write standing now requires an explicit scope grant outside the default + collection.** The pre-three-tier implicit-on-public write baseline (a granted + deployment `contributor` may propose on any public project with no membership + row) is **narrowed to the migration-seeded `default` collection only** (the N=1 + case, §22.13). On every *explicitly-created* collection — and on every project + beyond the default — proposing, discussing, branching, and contributing now + require an explicit `{owner, contributor}` grant at the collection, its + project, or global scope. Reads of public collections are unchanged. +- Entry-scoped authority checks (mark-reviewed, graduate, branch read/contribute, + PR/discussion/contribution moderation) are re-pointed from the project grain to + the entry's **collection** grain, so a collection Owner administers exactly + their collection's subtree and no more. + +Upgrade steps: + +1. The schema migration (`030_global_scope.sql`) runs automatically on deploy and + is backward-data-compatible — existing `memberships` rows are preserved. No + operator action is required for the migration itself. +2. **A single-collection (N=1) deployment needs no further action.** The `default` + collection keeps the implicit-on-public write baseline, so existing granted + contributors keep proposing exactly as before. +3. **A deployment that has created additional collections (S2) MUST grant scope + roles to its contributors.** Any contributor who should write in a non-default + collection (or in a second project) now needs an explicit grant: a + `memberships` row at `scope_type` `collection` (that collection), `project` + (its project — covers every collection within), or `global` (`scope_id='*'` — + every project). A deployment owner/admin is unaffected (global Owner by role). +4. To make a collection **hidden from the public**, set `visibility: gated` in its + `.collection.yaml` (or the project's `visibility` in `projects.yaml`); the + value MUST be as strict or stricter than the parent project's. The mirror + clamps a looser value and logs a warning. + +## 0.41.0 — 2026-06-05 + +**Minor (non-breaking) — §22 three-tier refactor, slice S2: *create & navigate a +second collection.* A deployment can now host more than one RFC collection per +project: a second collection is created, navigated, and proposed into beside the +default one. Purely additive — a single-collection deployment is unchanged (its +`/p//` still redirects into the sole collection, C3.7/C3.8). Completes +acceptance scenario `@S2` (C3.6: an anonymous reader of an empty public +collection sees an empty catalog with no propose action and a sign-in prompt).** + +See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md) +(Part E slice S2) and the slice plan +[`docs/design/plans/2026-06-05-s2-second-collection.md`](./docs/design/plans/2026-06-05-s2-second-collection.md). +Scoped {owner, contributor} roles at the collection axis remain S3; the +role-keyed create/propose-first empty states remain S4. + +Added: + +- **Named collections via `.collection.yaml`** — the registry mirror walks each + project's content repo and upserts a collection per + `/.collection.yaml` manifest (`type`, optional `visibility` / + `initial_state` / `name`; `type` immutable per §22.4a, visibility inherits the + project's when omitted). The default collection still flows from + `projects.yaml`. +- **create-collection** — `POST /api/projects//collections` (deployment + owner/admin). The bot commits a `.collection.yaml` to the content repo's + `main`, then the registry re-mirrors so the `collections` row appears (the + registry stays the source of truth, §22.2). New reads + `GET /api/projects//collections` and `…/collections/`. +- **Collection-scoped serve + propose** — + `GET /api/projects//collections//rfcs[/]` and + `POST …/collections//rfcs/propose`. A propose writes the entry under the + target collection's `/rfcs/`. +- **Collection directory at `/p//`** — lists the project's visible + collections, or redirects into the sole one when there is exactly one + (preserving the S1 single-collection UX). The catalog rail + entry views read + the active `/c//` segment and scope to it. + +Changed: + +- **The corpus mirror is collection-grained** — `cache.refresh_meta_repo` + iterates each project's collections and reads each collection's + `/rfcs/`, keying `cached_rfcs` by `collection_id`. The default + collection keeps the shipped repo-root `rfcs/` path; N=1 serving is unchanged. + +> ### Upgrade steps (0.40.0 → 0.41.0) +> +> - No required operator action — the slice is additive and the default-collection +> paths are unchanged. A deployment **MAY** deploy this version with no config +> change and keep running exactly as on 0.40.0. +> - To add a second collection, a deployment owner/admin **MAY** call +> `POST /api/projects//collections` (or commit a `/.collection.yaml` +> to the content repo directly); the registry mirror picks it up on the next +> refresh. +> - Deployments that pin the framework version **MUST** bump their version pin to +> `0.41.0`. + +## 0.40.0 — 2026-06-05 + +**Minor (breaking URL) — §22 three-tier refactor, slice S1: the *collection* +grain. A deployment now hosts N projects, each owning one content repo and +holding N RFC *collections*, each collection a typed corpus. S1 inserts the +collection grain beneath today's project as the invisible default: the +deployment runs exactly as before, now with a real collection layer and one +extra `/c//` URL segment. Per-corpus configuration (`type`, +`initial_state`), entry keys, and membership move to the collection grain; +no operator action is required beyond deploying.** + +See [`docs/design/2026-06-05-three-tier-projects-collections.md`](./docs/design/2026-06-05-three-tier-projects-collections.md) +(Part A model, Part E / §A.6 migration strategy). The structural model (Parts +A–D) is unaffected; the SPEC.md merge itself rides slice S6. + +Added: + +- **Migration `029_collections.sql`** — (1) a `collections` table + `(id, project_id, type, subfolder, initial_state, visibility, name, + registry_sha)` beneath `projects`; (2) the per-corpus fields (`type`, + `initial_state`) move **down** off `projects` onto the collection; (3) one + **default collection** per project (`id='default'` for the standard + single-project deployment, `subfolder` = repo root), inheriting the project's + type / initial_state / visibility; (4) the 13 entry-corpus tables re-key + `(project_id, slug)` → `(collection_id, slug)` via the migration-028 rebuild + pattern, each row mapped to its project's default collection; (5) + `project_members` generalises into **`memberships(scope_type ∈ {project, + collection}, scope_id, user_id, role, …)`** with the role enum collapsed to + `{owner, contributor}` (M2's `project_admin`→`owner`, + `project_contributor`→`contributor`; `project_viewer` folded into + `contributor` this pass — the read-only tier is deferred). +- **`app/collections.py`** — collection resolution helpers + (`default_collection_id`, `collection_type`, `collection_initial_state`, + `project_of_collection`). +- **`/c//` URL segment** — the canonical entry route is now + `/p//c//e/`. The project landing + `/p//` redirects into the project's single (default) collection + (C3.7); the deployment root `/` continues to redirect into the sole project + (C3.8). + +Changed: + +- **The registry mirror** writes a project's grouping-tier fields (name, + content_repo, visibility, config) to `projects` and the per-corpus fields + (`type`, `initial_state`) to its default collection; §22.4a type-immutability + is now enforced on the collection. +- **Backend threading** — `auth.project_of_rfc` recovers a project by joining + `collections`; `auth.project_member_role` reads `memberships`; + `cache`/`api_*`/`funder` writers + readers key the 13 entry-corpus tables by + `collection_id` (the denormalised `project_id` tags on + `cached_prs`/`threads`/`changes`/`notifications`/`actions`/`pr_resolution_branches` + are unchanged). Serving stays project-scoped (collection = default); the + registry `.collection.yaml` reader and collection-aware serving land in S2. + +Breaking: + +- **`/p//e/` URLs gain a `/c//` segment.** The + shipped v0.35.0 `/p//e/` form is preserved by a client-side + redirect into the default collection; the pre-multi-project `/rfc/` and + `/proposals/` server 308s now target `/p//c//…`. + +> ### Upgrade steps (0.39.0 → 0.40.0) +> +> - A deployment **MUST** deploy this version with its migrations applied (the +> standard startup path runs `029_collections.sql` automatically); the +> migration seeds the default collection and re-keys existing entries with no +> data loss. No configuration change is required. +> - Operators **SHOULD** be aware that the canonical entry URL is now +> `/p//c/default/e/`. Existing `/p//e/`, +> `/rfc/`, and `/proposals/` links keep working (client redirect / +> server 308). External systems that hardcoded the old form **SHOULD** be +> updated to the collection-scoped form at their convenience. +> - Deployments that pin the framework version **MUST** bump their version pin +> to `0.40.0`. + +Deferred (later slices): creating + navigating a second collection and the +registry `.collection.yaml` reader (S2); the four-layer scope-role resolver and +the `viewer` read tier (S3); invitation surfaces (S4); in-app create-project +(S5); per-type surfaces, membership lifecycle, and the SPEC.md merge (S6). + ## 0.39.0 — 2026-06-04 **Minor — §22.13 step 1: the default-project-id re-stamp. A deployment can diff --git a/VERSION b/VERSION index 4ef2eb0..787ffc3 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.39.0 +0.42.0 diff --git a/backend/app/api.py b/backend/app/api.py index 0318b48..60be3f5 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -21,6 +21,7 @@ from pydantic import BaseModel, Field from . import ( api_admin, api_branches, + api_collections, api_contributions, api_deployment, api_discussion, @@ -29,6 +30,7 @@ from . import ( api_notifications, api_prs, auth, + collections as collections_mod, projects as projects_mod, db, device_trust as device_trust_mod, @@ -151,6 +153,7 @@ def make_router( # §22.9/§22.10 (M3): runtime deployment + per-project config (replaces # VITE_APP_NAME) + the old-URL 308 redirects. router.include_router(api_deployment.make_router(config)) + router.include_router(api_collections.make_router(config, gitea, bot)) # --------------------------------------------------------------- # §17: /api/health — unauthenticated post-flight probe. @@ -637,13 +640,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 +688,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. @@ -716,13 +719,15 @@ def make_router( # second project's corpus renders under /p//. # --------------------------------------------------------------- - @router.get("/api/projects/{project_id}/rfcs") - async def list_project_rfcs( - project_id: str, request: Request, unreviewed: str | None = None + def _require_collection_in_project(collection_id: str, project_id: str) -> None: + # §22 S2: a collection-scoped route 404s when the collection does not + # belong to the project in the path (shape matches an unknown id). + if collections_mod.project_of_collection(collection_id) != project_id: + raise HTTPException(404, "Not found") + + def _list_rfcs_for_collection( + collection_id: str, viewer, unreviewed: str | None ) -> dict[str, Any]: - 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) 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 +739,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 = [ @@ -766,13 +771,10 @@ def make_router( ] return {"items": items} - @router.get("/api/projects/{project_id}/rfcs/{slug}") - 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) + def _get_rfc_for_collection(collection_id: str, slug: str, viewer) -> dict[str, Any]: 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,14 +784,57 @@ 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 + @router.get("/api/projects/{project_id}/rfcs") + async def list_project_rfcs( + project_id: str, request: Request, unreviewed: str | None = None + ) -> dict[str, Any]: + 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: the project-scoped route serves the default collection. + collection_id = collections_mod.default_collection_id(project_id) + return _list_rfcs_for_collection(collection_id, viewer, unreviewed) + + @router.get("/api/projects/{project_id}/rfcs/{slug}") + 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) + return _get_rfc_for_collection(collection_id, slug, viewer) + + # §22 S2: collection-scoped serve + propose. The catalog/entry views read + # these under /p//c//; the project-scoped routes above + # stay as the default-collection compat surface. + @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs") + async def list_collection_rfcs( + project_id: str, collection_id: str, request: Request, + unreviewed: str | None = None, + ) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + _require_collection_in_project(collection_id, project_id) + # §22.5 (S3): a hidden/gated collection 404s to a non-scope-role viewer. + auth.require_collection_readable(viewer, collection_id) + return _list_rfcs_for_collection(collection_id, viewer, unreviewed) + + @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}") + async def get_collection_rfc( + project_id: str, collection_id: str, slug: str, request: Request + ) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + _require_collection_in_project(collection_id, project_id) + auth.require_collection_readable(viewer, collection_id) + return _get_rfc_for_collection(collection_id, slug, viewer) + # --------------------------------------------------------------- # §22.4c: mark-reviewed — clear an active entry's `unreviewed` flag # --------------------------------------------------------------- @@ -797,14 +842,16 @@ def make_router( @router.post("/api/projects/{project_id}/rfcs/{slug}/mark-reviewed") async def mark_reviewed(project_id: str, slug: str, request: Request) -> dict[str, Any]: """§22.4c — clear an active entry's `unreviewed` flag. Authority is the - §22.7 project superuser (project_admin or deployment owner/admin).""" + §B.2 collection Owner (a collection/project/global Owner or deployment + owner/admin reaching the entry's collection).""" viewer = auth.require_user(request) 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) + if not auth.is_collection_superuser(viewer, collection_id): + raise HTTPException(403, "Only a collection owner can mark an entry reviewed") 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") @@ -952,11 +999,20 @@ def make_router( # --------------------------------------------------------------- async def _propose_into_project(project_id: str, payload: ProposeBody, user) -> dict[str, Any]: - # §22.6/§22.7: proposing a new entry requires project-level contribute - # standing in the *target* project. On the public default project the - # implicit-public baseline preserves the pre-multi-project flow. - if not auth.can_contribute_in_project(user, project_id): - raise HTTPException(403, "You do not have contribute access to this project") + # Default-collection wrapper (§22 S1/S2): resolve the project's default + # collection and delegate. Keeps the project-scoped propose routes intact. + return await _propose_into_collection( + project_id, collections_mod.default_collection_id(project_id), payload, user + ) + + async def _propose_into_collection( + project_id: str, collection_id: str, payload: ProposeBody, user + ) -> dict[str, Any]: + # §B.2 (S3): proposing a new entry requires contribute standing in the + # *target collection* — the four-layer scope-role union, with the + # grandfathered implicit-public baseline on the default collection. + if not auth.can_contribute_in_collection(user, collection_id): + raise HTTPException(403, "You do not have contribute access to this collection") slug = payload.slug.strip().lower() if not entry_mod.is_valid_slug(slug): raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes") @@ -966,7 +1022,7 @@ def make_router( # on every keystroke, since a concurrent submission could land # between dialog-open and submit. 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") @@ -978,11 +1034,12 @@ def make_router( if idea_clash: raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal") - # §22.4b: the target project's landing state. Through Plan A every - # entry lands in the default project; M3-frontend routing carries a - # non-default target later. - target_project = project_id - landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft" + # §22.4b: the target collection's landing state (the per-corpus field + # moved down to the collection in migration 029). + landing_state = ( + "active" if collections_mod.collection_initial_state(collection_id) == "active" + else "super-draft" + ) entry = entry_mod.Entry( slug=slug, @@ -1013,6 +1070,9 @@ def make_router( f"**Topic:** {entry.title}\n\n" f"{payload.pitch.strip()}" ) + # §22 S2: write the entry under the target collection's /rfcs. + subfolder = collections_mod.subfolder_of(collection_id) + rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs" try: pr = await bot.open_idea_pr( user.as_actor(), @@ -1022,6 +1082,7 @@ def make_router( file_contents=contents, pr_title=pr_title, pr_description=pr_description, + rfcs_dir=rfcs_dir, ) except GiteaError as e: raise HTTPException(502, f"Gitea: {e.detail}") @@ -1041,11 +1102,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 = ?", @@ -1070,6 +1131,19 @@ def make_router( auth.require_project_readable(user, project_id) return await _propose_into_project(project_id, payload, user) + @router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose") + async def propose_collection_rfc( + project_id: str, collection_id: str, payload: ProposeBody, request: Request + ) -> dict[str, Any]: + # §22 S2: propose a new entry into a specific collection of a project. + user = auth.require_contributor(request) + auth.require_project_readable(user, project_id) + _require_collection_in_project(collection_id, project_id) + # §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer + # before the contribute check (existence is not revealed). + auth.require_collection_readable(user, collection_id) + return await _propose_into_collection(project_id, collection_id, payload, user) + # --------------------------------------------------------------- # §9.1 Slice 2 (roadmap #27): Claude Haiku tag suggestions as the # propose-RFC fields fill in. The modal debounce-posts the partial @@ -1205,12 +1279,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..6d46f4c 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 @@ -1264,11 +1264,11 @@ def make_router( return row["on_behalf_of"] if row else None def _can_read_branch(slug: str, branch: str, viewer) -> bool: - # §22.5 visibility gate first (subtractive, §22.7): in a gated project - # nothing — not even main or a read_public branch — is readable by a - # non-member. - pid = auth.project_of_rfc(slug) - if not auth.can_read_project(viewer, pid): + # §22.5 visibility gate first (subtractive, §B.2): in a hidden/gated + # collection nothing — not even main or a read_public branch — is + # readable by a non-scope-role viewer. + cid = auth.collection_of_rfc(slug) + if not auth.can_read_collection(viewer, cid): return False if branch == "main": return True @@ -1277,7 +1277,7 @@ def make_router( return True if viewer is None: return False - if auth.is_project_superuser(viewer, pid): + if auth.is_collection_superuser(viewer, cid): return True creator = _branch_creator(slug, branch) if creator and viewer.gitea_login == creator: @@ -1310,12 +1310,12 @@ def make_router( # legacy `repo:` is set (nothing, after the RFC-0001 fold-back). if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch): return False - pid = auth.project_of_rfc(slug) + cid = auth.collection_of_rfc(slug) # §22.5 visibility gate (subtractive): no contribute in an unreadable - # project. - if not auth.can_read_project(viewer, pid): + # collection. + if not auth.can_read_collection(viewer, cid): return False - if auth.is_project_superuser(viewer, pid): + if auth.is_collection_superuser(viewer, cid): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1326,10 +1326,10 @@ def make_router( return True vis = _branch_vis(slug, branch) if vis["contribute_mode"] == "any-contributor": - # "any contributor" means anyone with project-level write standing - # (§22.6/§22.7) — the implicit-public baseline on a public project, - # or an explicit project_contributor/admin elsewhere. - return auth.can_contribute_in_project(viewer, pid) + # "any contributor" means anyone with collection-level write standing + # (§B.2) — the grandfathered baseline on the public default + # collection, or an explicit scope grant reaching the collection. + return auth.can_contribute_in_collection(viewer, cid) if vis["contribute_mode"] == "specific": row = db.conn().execute( """ @@ -1352,7 +1352,7 @@ def make_router( def _require_branch_owner(rfc, viewer, creator: str | None) -> None: # §22.6: a project_admin is the per-RFC owner/arbiter authority lifted # to project scope, so it (and a deployment owner/admin) clears here. - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1368,7 +1368,7 @@ def make_router( has no owners, so the set collapses to the superuser tier only — sensible because admin oversight is the only path to canonicalizing edits on an unclaimed entry.""" - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1381,7 +1381,7 @@ def make_router( "can_read": _can_read_branch(slug, branch, viewer), "can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False, "can_change_branch_settings": viewer is not None and ( - auth.is_project_superuser(viewer, rfc["project_id"]) + auth.is_collection_superuser(viewer, rfc["collection_id"]) or (creator is not None and viewer.gitea_login == creator) or viewer.gitea_login in (owners + arbiters) ), @@ -1427,7 +1427,7 @@ def make_router( def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool: if viewer is None: return False - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_collections.py b/backend/app/api_collections.py new file mode 100644 index 0000000..2f990d4 --- /dev/null +++ b/backend/app/api_collections.py @@ -0,0 +1,136 @@ +"""§22 S2 — collection directory + create-collection. + +GET /api/projects/:id/collections — list the project's visible collections. +GET /api/projects/:id/collections/:cid — one collection's settings. +POST /api/projects/:id/collections — create a collection. Authorized by a + deployment owner/admin (S2; scoped + {owner, contributor} roles at the + collection axis land in S3). The bot + commits a `.collection.yaml` to the + content repo, then the registry mirror + upserts the collections row — §22.2 + keeps the registry the source of truth. +""" +from __future__ import annotations + +import re +from typing import Any + +import yaml +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from . import ( + auth, + collections as collections_mod, + projects as projects_mod, + registry as registry_mod, +) +from .bot import Bot +from .config import Config +from .gitea import Gitea, GiteaError + +_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") + + +class CreateCollectionBody(BaseModel): + collection_id: str + type: str + name: str | None = None + visibility: str | None = None + initial_state: str | None = None + + +def make_router(config: Config, gitea: Gitea, bot: Bot) -> APIRouter: + router = APIRouter() + + @router.get("/api/projects/{project_id}/collections") + async def list_cols(project_id: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + # §22.5 read gate: a gated project 404s a non-member. + auth.require_project_readable(viewer, project_id) + # §22.5 (S3): the directory is viewer-aware — a hidden/gated collection + # is listed only for a scope-role holder who can read it; `unlisted` is + # omitted from enumeration for everyone (link-only). + items = [ + c + for c in collections_mod.list_collections(project_id, include_unlisted=True) + if c["visibility"] != "unlisted" and auth.can_read_collection(viewer, c["id"]) + ] + return {"items": items} + + @router.get("/api/projects/{project_id}/collections/{collection_id}") + async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + col = collections_mod.get_collection(collection_id) + if col is None or col["project_id"] != project_id: + raise HTTPException(404, "Not found") + # §22.5 (S3): a hidden/gated collection 404s a non-scope-role viewer. + auth.require_collection_readable(viewer, collection_id) + return col + + @router.post("/api/projects/{project_id}/collections") + async def create_col( + project_id: str, body: CreateCollectionBody, request: Request + ) -> dict[str, Any]: + # §B.1 (S3) authority: a deployment owner/admin or a project/global-scope + # grant holder (Owner or RFC Contributor) may create a collection. The + # read gate runs first so a gated project 404s a non-member. + user = auth.require_contributor(request) + auth.require_project_readable(user, project_id) + if not auth.can_create_collection(user, project_id): + raise HTTPException(403, "You may not create collections in this project") + cid = body.collection_id.strip().lower() + if not _SLUG_RE.match(cid) or cid == "default": + raise HTTPException(422, "collection id must be a slug and not 'default'") + if body.type not in registry_mod.VALID_TYPES: + raise HTTPException(422, f"invalid type {body.type!r}") + if body.visibility is not None: + if body.visibility not in registry_mod.VALID_VISIBILITY: + raise HTTPException(422, f"invalid visibility {body.visibility!r}") + # §22.5 (S3) strictness: a collection may be set only as strict or + # stricter than its project — never more public. + pvis = auth.project_visibility(project_id) + if auth.visibility_rank(body.visibility) < auth.visibility_rank(pvis): + raise HTTPException( + 422, + f"collection visibility {body.visibility!r} is looser than " + f"the project's {pvis!r}; a collection may only narrow it", + ) + if body.initial_state is not None and body.initial_state not in registry_mod.VALID_INITIAL_STATE: + raise HTTPException(422, f"invalid initial_state {body.initial_state!r}") + if collections_mod.get_collection(cid) is not None: + raise HTTPException(409, f"collection `{cid}` already exists") + content_repo = projects_mod.content_repo(project_id) + if not content_repo: + raise HTTPException(409, "project has no content repo") + + manifest: dict[str, Any] = {"type": body.type} + if body.name: + manifest["name"] = body.name + if body.visibility: + manifest["visibility"] = body.visibility + if body.initial_state: + manifest["initial_state"] = body.initial_state + manifest_yaml = yaml.safe_dump(manifest, sort_keys=False) + + try: + await bot.create_collection( + user.as_actor(), + org=config.gitea_org, + content_repo=content_repo, + collection_id=cid, + manifest_yaml=manifest_yaml, + ) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + + # §22.2: re-read the registry so the new manifest becomes a row. + await registry_mod.refresh_registry(config, gitea) + col = collections_mod.get_collection(cid) + if col is None: + raise HTTPException(500, "collection committed but not mirrored") + return col + + return router diff --git a/backend/app/api_contributions.py b/backend/app/api_contributions.py index 5a4c0b8..bf6ff4b 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: @@ -91,7 +91,7 @@ def _viewer_relationship(viewer, slug: str) -> str | None: """Why this viewer can't *request* to contribute — or None if they can. Owners/admins already have the RFC; existing collaborators are already in. Both get a clear 409 rather than a useless self-request.""" - if auth.is_rfc_owner(viewer, slug) or auth.is_project_superuser(viewer, auth.project_of_rfc(slug)): + if auth.is_rfc_owner(viewer, slug) or auth.is_collection_superuser(viewer, auth.collection_of_rfc(slug)): return "You already own or administer this RFC." if auth.is_rfc_collaborator(viewer, slug): return "You're already a collaborator on this RFC." @@ -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..840861c 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") @@ -312,7 +312,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int: def _can_resolve(rfc, thread, viewer) -> bool: if viewer is None: return False - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index 6c61adb..74a472a 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -218,7 +218,7 @@ def make_router( can_merge = ( viewer is not None and ( - auth.is_project_superuser(viewer, rfc["project_id"]) + auth.is_collection_superuser(viewer, rfc["collection_id"]) or viewer.gitea_login in owners or viewer.gitea_login in arbiters ) @@ -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": @@ -795,7 +795,7 @@ def _can_graduate(rfc, viewer) -> bool: if viewer is None: return False # §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters. - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") 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..25ecc17 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 @@ -786,7 +786,7 @@ def _can_merge(rfc, viewer) -> bool: """§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters.""" if viewer is None: return False - if auth.is_project_superuser(viewer, rfc["project_id"]): + if auth.is_collection_superuser(viewer, rfc["collection_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/auth.py b/backend/app/auth.py index 8461171..4f529ad 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -16,6 +16,7 @@ from typing import Any import httpx from fastapi import HTTPException, Request +from . import collections as collections_mod from . import db from .bot import Actor from .config import Config @@ -336,31 +337,73 @@ def project_visibility(project_id: str) -> str: return row["visibility"] or "gated" +def _is_default_project(project_id: str) -> bool: + """True iff `project_id` owns the migration-seeded `default` collection — the + deployment's primary project (§22.13), whatever its configured id. Only there + do M2's role rows (which migrated to collection scope `default`) stand in for + project-level authority.""" + return collections_mod.project_of_collection(collections_mod.DEFAULT_COLLECTION_ID) == project_id + + 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 *project-grain* §22.6 role at this project, or None — the + most-permissive of a **global** grant (inherits down to every project) and a + **project**-scope grant. Mapped back to the legacy + `project_admin`/`project_contributor` strings the project-grain authz speaks. + + Back-compat: on the deployment's *default* project only, M2's rows live at + collection scope `default` (§B.3 migration), so a `default` collection-scope + grant there is read as project-level too. A collection grant on any other + project is NOT project authority — that is the four-layer collection resolver + (`effective_scope_role`). Does not fold in the deployment tier + (`is_project_superuser` adds it) or the implicit-on-public baseline.""" if user is None: return None + clauses = ["scope_type = 'global'", "(scope_type = 'project' AND scope_id = ?)"] + params: list = [user.user_id, project_id] + if _is_default_project(project_id): + clauses.append("(scope_type = 'collection' AND scope_id = ?)") + params.append(collections_mod.DEFAULT_COLLECTION_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 (" + " OR ".join(clauses) + ") " + "ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1", + params, ).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 return row["project_id"] or DEFAULT_PROJECT_ID +def collection_of_rfc(rfc_slug: str) -> str: + """The collection an RFC belongs to (`cached_rfcs.collection_id`). Falls back + to the default collection when the slug isn't cached. Mirrors + `project_of_rfc`'s first-match semantics; a slug shared across collections is + a known routing ambiguity (the RFC-grain helpers take a bare slug) resolved + by the collection-qualified routes in later slices.""" + row = db.conn().execute( + "SELECT collection_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,) + ).fetchone() + if row is None or not row["collection_id"]: + return collections_mod.DEFAULT_COLLECTION_ID + return row["collection_id"] + + def is_project_superuser(user: SessionUser | None, project_id: str) -> bool: """Maximal authority within a project: a deployment owner/admin (superuser in every project, §22.7) or an explicit `project_admin` (§22.6). Both @@ -373,20 +416,30 @@ def is_project_superuser(user: SessionUser | None, project_id: str) -> bool: def can_read_project(user: SessionUser | None, project_id: str) -> bool: - """The §22.5 visibility gate. `public`/`unlisted` are readable by anyone - (anonymous included — `unlisted` is link-only but the link still reads); - `gated` is readable only by a deployment owner/admin or a granted project - member of any role. Used as the subtractive read gate (a gated project's - entries 404 to non-members).""" + """The §22.5 visibility gate at the project grain. `public`/`unlisted` are + readable by anyone (anonymous included — `unlisted` is link-only but the link + still reads); `gated` is readable only by a deployment owner/admin or a + holder of any scope grant reaching the project — a global grant, a project + grant, or membership at *any* collection within it (seeing a collection + implies seeing its project). Used as the subtractive read gate (a gated + project's entries 404 to non-members).""" vis = project_visibility(project_id) if vis in ("public", "unlisted"): return True - # gated — members + superusers only, subject to the §6 admission floor. + # gated — scope-role holders + superusers only, subject to the §6 floor. if user is None or user.permission_state != "granted": return False if user.role in _DEPLOYMENT_SUPERUSER_ROLES: return True - return project_member_role(user, project_id) is not None + row = db.conn().execute( + "SELECT 1 FROM memberships m WHERE m.user_id = ? AND (" + " m.scope_type = 'global'" + " OR (m.scope_type = 'project' AND m.scope_id = ?)" + " OR (m.scope_type = 'collection' AND m.scope_id IN " + " (SELECT id FROM collections WHERE project_id = ?))) LIMIT 1", + (user.user_id, project_id, project_id), + ).fetchone() + return row is not None def require_project_readable(user: SessionUser | None, project_id: str) -> None: @@ -448,6 +501,187 @@ def visible_project_ids(user: SessionUser | None) -> list[str]: return [r["id"] for r in rows if can_read_project(user, r["id"])] +# =========================================================================== +# §22 three-tier — S3. The four-layer scope-role resolver (§B.2) and the +# collection-grain visibility gate. +# +# A grant attaches the unified role {owner, contributor} at a scope: global, +# project, or collection (§B.1). Grants inherit downward, are additive, and +# admit no negative override (§B.2). Effective authority over a *collection* is +# the most-permissive union of the layers reaching it: +# +# global (users.role owner/admin ∪ memberships scope_type='global') +# ∪ project (memberships scope_type='project' at the collection's project) +# ∪ collection (memberships scope_type='collection' at the collection) +# +# minus the §22.5 visibility gate and §6.2 write-mute (subtractive, as today). +# Per-entry authority (owners / arbiters / rfc_collaborators) is a distinct, +# finer layer the RFC-grain helpers union in beneath collection. +# +# OPERATOR DECISIONS (S3, session 0076): +# * scope-role-primary — a plain granted account (users.role 'contributor', no +# membership) is a granted *account*, not a write-everywhere global role. +# Write standing comes from an explicit scope grant; the lone exception is the +# grandfathered implicit-public baseline below. +# * grandfathered baseline — the migration-seeded `default` collection keeps the +# pre-three-tier implicit-on-public write baseline (a granted deployment +# contributor may propose while it is public), so the N=1 deployment (§22.13) +# loses no capability. Every *explicitly-created* collection requires an +# explicit scope grant to write. +# * hidden-from-public — a `gated` collection is invisible to the public (404, +# omitted from the directory) yet visible to any scope-role holder reaching it +# (collection/project/global). A collection's visibility may be set only as +# strict or stricter than its project's (the rank ordering below). +# =========================================================================== + +# The global scope is a single tier per deployment; its grant rows use this +# sentinel scope_id (migration 030). +GLOBAL_SCOPE_ID = "*" + +# §22.5 visibility strictness on the public-exposure axis: `public` is least +# strict, `gated` most strict. A collection may narrow its project's visibility +# but never widen it (`rank(collection) >= rank(project)`). +_VISIBILITY_RANK = {"public": 0, "unlisted": 1, "gated": 2} + + +def visibility_rank(visibility: str | None) -> int: + """The strictness rank of a §22.5 visibility (higher = stricter). An unknown + value reads as the strictest (`gated`) — the safe default.""" + return _VISIBILITY_RANK.get(visibility or "", _VISIBILITY_RANK["gated"]) + + +def effective_scope_role(user: SessionUser | None, collection_id: str) -> str | None: + """The most-permissive unified role ({'owner','contributor'}) the user holds + over `collection_id`, folding §B.2's global → project → collection layers. + Returns None when no scope grant reaches the collection. 'owner' outranks + 'contributor'; there is no negative override (a parent grant is never + subtracted by a child). Subject to the §6 admission floor.""" + if user is None or user.permission_state != "granted": + return None + # Global tier — a deployment owner/admin is a global Owner (§B.1). + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return "owner" + pid = collections_mod.project_of_collection(collection_id) + row = db.conn().execute( + "SELECT role FROM memberships " + "WHERE user_id = ? AND (" + " scope_type = 'global'" + " OR (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, pid, collection_id), + ).fetchone() + return row["role"] if row else None + + +def collection_visibility(collection_id: str) -> str: + """The collection's own §22.5 visibility. A missing row reads as 'gated' — + an unknown collection is invisible rather than open.""" + row = db.conn().execute( + "SELECT visibility FROM collections WHERE id = ?", (collection_id,) + ).fetchone() + if row is None or not row["visibility"]: + return "gated" + return row["visibility"] + + +def effective_collection_visibility(collection_id: str) -> str: + """The stricter of the collection's own visibility and its project's (§22.5 + 'both gates'). A collection is constrained to be ≥ its project in strictness, + but we max() defensively so a misconfigured looser collection can never widen + its project's gate.""" + cvis = collection_visibility(collection_id) + pid = collections_mod.project_of_collection(collection_id) + pvis = project_visibility(pid) if pid else "gated" + return cvis if visibility_rank(cvis) >= visibility_rank(pvis) else pvis + + +def can_read_collection(user: SessionUser | None, collection_id: str) -> bool: + """§22.5 read/existence gate at the collection grain. `public`/`unlisted` + read by anyone (anonymous included — `unlisted` is link-only but the link + reads); `gated` ("hidden from public existence") reads only for a scope-role + holder over the collection (collection/project/global) or a deployment + owner/admin. The subtractive read gate — a gated collection 404s a + non-holder, indistinguishable from absent.""" + vis = effective_collection_visibility(collection_id) + if vis in ("public", "unlisted"): + return True + if user is None or user.permission_state != "granted": + return False + return effective_scope_role(user, collection_id) is not None + + +def require_collection_readable(user: SessionUser | None, collection_id: str) -> None: + """Raise 404 when the collection is not readable by this viewer (§22.5: a + hidden/gated collection is invisible to non-holders — the shape matches an + unknown collection).""" + if not can_read_collection(user, collection_id): + raise HTTPException(status_code=404, detail="Not found") + + +def is_collection_superuser(user: SessionUser | None, collection_id: str) -> bool: + """Maximal authority over a collection: an effective scope role of 'owner' + reaching it (a collection Owner, a project Owner of its project, a global + Owner, or a deployment owner/admin). Subsumes the per-entry owners/arbiters + tier within the collection.""" + return effective_scope_role(user, collection_id) == "owner" + + +def _has_collection_write_baseline(user: SessionUser | None, collection_id: str) -> bool: + """The grandfathered implicit-on-public write baseline, narrowed to the + migration-seeded `default` collection (§22.13 N=1 case). A granted deployment + `contributor` keeps its pre-three-tier write standing on the default + collection while its effective visibility is public; every explicitly-created + collection requires an explicit scope grant (S3 operator decision).""" + if user is None or user.permission_state != "granted": + return False + if collection_id != collections_mod.DEFAULT_COLLECTION_ID: + return False + return user.role == "contributor" and effective_collection_visibility(collection_id) == "public" + + +def can_contribute_in_collection(user: SessionUser | None, collection_id: str) -> bool: + """May the user contribute *new* content to the collection (propose an entry) + — the collection-level contribute standing. The union of the scope-role grant + (owner/contributor reaching the collection) and the grandfathered default + baseline, subject to the visibility read gate.""" + if user is None or user.permission_state != "granted": + return False + if not can_read_collection(user, collection_id): + return False + if effective_scope_role(user, collection_id) is not None: + return True + return _has_collection_write_baseline(user, collection_id) + + +def can_discuss_in_collection(user: SessionUser | None, collection_id: str) -> bool: + """May the user participate in discussion in the collection — the + collection-level discuss standing. A superset of contribute for this pass + (the read-only viewer tier is deferred, §B.3), so it mirrors + `can_contribute_in_collection`.""" + return can_contribute_in_collection(user, collection_id) + + +def can_create_collection(user: SessionUser | None, project_id: str) -> bool: + """May the user create a new collection in this project (§B.1)? Creating a + collection is a *project-level* action: a deployment owner/admin, or any + holder of a project-scope or global-scope grant (Owner OR RFC Contributor — + 'anyone at the project level with permission to create a collection'). A + *collection*-scope grant cannot create sibling collections.""" + if user is None or user.permission_state != "granted": + return False + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return True + row = db.conn().execute( + "SELECT 1 FROM memberships " + "WHERE user_id = ? AND (" + " scope_type = 'global'" + " OR (scope_type = 'project' AND scope_id = ?)) LIMIT 1", + (user.user_id, project_id), + ).fetchone() + return row is not None + + # v0.16.0 (roadmap item #12): per-RFC membership helpers. # # These don't replace `require_contributor` — they layer on top of it for @@ -544,16 +778,14 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - pid = project_of_rfc(rfc_slug) - # §22.5 visibility gate is subtractive (§22.7) — no capability in a project - # the viewer cannot even read. - if not can_read_project(user, pid): + cid = collection_of_rfc(rfc_slug) + # §22.5 visibility gate is subtractive (§22.7) — no capability in a + # collection the viewer cannot even read. + if not can_read_collection(user, cid): return False - # §22.7 union, override grants first — these bypass per-RFC curation - # (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all). - if is_project_superuser(user, pid): - return True - if project_member_role(user, pid) in ("project_viewer", "project_contributor"): + # §B.2 union, scope-role grants first — these bypass per-RFC curation (a + # collection/project/global Owner or RFC Contributor ⊇ discussant). + if effective_scope_role(user, cid) is not None: return True # per-RFC authority (union term). owners = _rfc_owners_set(rfc_slug) @@ -561,11 +793,11 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return True if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None): return True - # implicit-public baseline (curation preserved): a granted deployment - # contributor on a public project may discuss only while the RFC is - # unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the + # grandfathered implicit-public baseline (curation preserved): on the default + # collection a granted deployment contributor may discuss only while the RFC + # is unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the # pre-multi-project v0.16.0 contract. - if not owners and _has_write_baseline(user, pid): + if not owners and _has_collection_write_baseline(user, cid): return True return False @@ -589,14 +821,12 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - pid = project_of_rfc(rfc_slug) - if not can_read_project(user, pid): + cid = collection_of_rfc(rfc_slug) + if not can_read_collection(user, cid): return False - # §22.7 union, override grants first (project_contributor ⊇ - # rfc_collaborators(contributor); project_admin / superuser ⊇ all). - if is_project_superuser(user, pid): - return True - if project_member_role(user, pid) == "project_contributor": + # §B.2 union, scope-role grants first (a collection/project/global RFC + # Contributor ⊇ rfc_collaborators(contributor); an Owner ⊇ all). + if effective_scope_role(user, cid) is not None: return True # per-RFC authority (union term). A 'discussant' row is NOT sufficient — # PRs are the higher-privilege surface. @@ -605,9 +835,10 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return True if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"): return True - # implicit-public baseline (curation preserved): until an owner exists, a - # granted deployment contributor on a public project may contribute. - if not owners and _has_write_baseline(user, pid): + # grandfathered implicit-public baseline (curation preserved): until an owner + # exists, a granted deployment contributor on the public default collection + # may contribute. + if not owners and _has_collection_write_baseline(user, cid): return True return False @@ -620,13 +851,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - pid = project_of_rfc(rfc_slug) - if not can_read_project(user, pid): + cid = collection_of_rfc(rfc_slug) + if not can_read_collection(user, cid): return False - # Deployment owner/admin or project_admin (§22.6) may invite; otherwise - # only the RFC's frontmatter owner. Per-RFC collaborators and the - # implicit-public baseline do not get the invite-others power. - if is_project_superuser(user, pid): + # An Owner reaching the collection (collection/project/global Owner, or a + # deployment owner/admin) may invite; otherwise only the RFC's frontmatter + # owner. Per-RFC collaborators and the baseline do not get the invite power. + if is_collection_superuser(user, cid): return True return is_rfc_owner(user, rfc_slug) diff --git a/backend/app/bot.py b/backend/app/bot.py index f6ea9c3..f985f23 100644 --- a/backend/app/bot.py +++ b/backend/app/bot.py @@ -163,6 +163,41 @@ class Bot: def __init__(self, gitea: Gitea): self._gitea = gitea + # ----- Content repo: collection structure (§22 S2) ----- + + async def create_collection( + self, + actor: Actor, + *, + org: str, + content_repo: str, + collection_id: str, + manifest_yaml: str, + ) -> dict: + """§22 S2: commit `/.collection.yaml` to the content + repo's main. A structural admin action — committed straight to main (no + PR), like the registry config it feeds; the registry mirror then upserts + the collections row (§22.2 keeps the registry the source of truth). Logs + an audit row for the §6.5 trail.""" + path = f"{collection_id}/.collection.yaml" + created = await self._gitea.create_file( + org, + content_repo, + path, + content=manifest_yaml, + message=_stamp_single(f"chore: create collection {collection_id}", actor), + branch="main", + author_name=actor.display_name, + author_email=actor.email or f"{actor.gitea_login}@users.noreply", + ) + _log( + actor, + "create_collection", + bot_commit_sha=created.get("commit", {}).get("sha"), + details={"collection_id": collection_id, "repo": content_repo}, + ) + return created + # ----- Meta repo: idea PRs (§9.1 / §9.2) ----- async def open_idea_pr( @@ -175,12 +210,16 @@ class Bot: file_contents: str, pr_title: str, pr_description: str, + rfcs_dir: str = "rfcs", ) -> dict: - """Per §9.1: open a meta-repo PR adding one file under rfcs/. + """Per §9.1: open a meta-repo PR adding one file under `/`. One file per PR keeps idea submissions atomic and conflict-free. The PR title and the file-add commit subject share §9.2's fixed - pattern; callers compose `pr_title` as `Propose: `. + pattern; callers compose `pr_title` as `Propose: <Title>`. §22 S2: + `rfcs_dir` carries the target collection's `<subfolder>/rfcs` so a + propose into a named collection writes under its subfolder; it + defaults to `rfcs` (the default collection / shipped behaviour). """ branch = f"propose/{slug}" await self._gitea.create_branch(org, meta_repo, branch, from_branch="main") @@ -189,7 +228,7 @@ class Bot: created = await self._gitea.create_file( org, meta_repo, - f"rfcs/{slug}.md", + f"{rfcs_dir}/{slug}.md", content=file_contents, message=commit_message, branch=branch, diff --git a/backend/app/cache.py b/backend/app/cache.py index 4af2ab2..aaf9f70 100644 --- a/backend/app/cache.py +++ b/backend/app/cache.py @@ -54,10 +54,27 @@ 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 S2: the corpus grain is the collection. Mirror every collection of the + # project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the + # collection id. The default collection (subfolder '') reads `rfcs/` — the + # shipped path, unchanged. include_unlisted: the mirror serves every + # collection's content regardless of enumeration visibility. + from . import collections as collections_mod + for col in collections_mod.list_collections(project_id, include_unlisted=True): + await _refresh_collection_corpus( + org, project_id, repo, col["id"], col["subfolder"] or "", gitea + ) + + +async def _refresh_collection_corpus( + org: str, project_id: str, repo: str, collection_id: str, subfolder: str, gitea: Gitea +) -> None: + rfcs_dir = f"{subfolder}/rfcs" if subfolder else "rfcs" try: - files = await gitea.list_dir(org, repo, "rfcs", ref="main") + files = await gitea.list_dir(org, repo, rfcs_dir, ref="main") except GiteaError as e: - log.warning("refresh_meta_repo: project %s: cannot list rfcs/: %s", project_id, e) + log.warning("refresh_meta_repo: %s/%s: cannot list %s: %s", + project_id, collection_id, rfcs_dir, e) return seen_slugs: set[str] = set() @@ -71,28 +88,31 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G try: entry = entry_mod.parse(text) except Exception as parse_err: - log.warning("refresh_meta_repo: %s: skipping %s: %s", project_id, f["path"], parse_err) + log.warning("refresh_meta_repo: %s/%s: skipping %s: %s", + project_id, collection_id, f["path"], parse_err) continue if not entry.slug: - log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"]) + log.warning("refresh_meta_repo: %s/%s: skipping %s: missing slug", + project_id, collection_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 + # Entries removed from a collection's rfcs/ — the spec keeps withdrawn entries # as historical record (§3), so this fires only for out-of-band deletes; - # leave the row, scoped to this project, for reconciler attention. + # leave the row, scoped to this collection, for reconciler attention. 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) + log.info("refresh_meta_repo: %s/%s/%s no longer present — leaving cache row", + project_id, collection_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 +125,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 +170,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 +230,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 +405,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 +427,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..d5b8f9c --- /dev/null +++ b/backend/app/collections.py @@ -0,0 +1,86 @@ +"""§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" + + +def subfolder_of(collection_id: str) -> str: + """The content-repo subfolder a collection lives under (§22.3). Empty string + for the default collection (entries at the repo root `rfcs/`).""" + row = db.conn().execute( + "SELECT subfolder FROM collections WHERE id = ?", (collection_id,) + ).fetchone() + return (row["subfolder"] if row else "") or "" + + +def get_collection(collection_id: str) -> dict | None: + """The full collection row as a dict, or None if unknown.""" + row = db.conn().execute( + "SELECT id, project_id, type, subfolder, initial_state, visibility, name " + "FROM collections WHERE id = ?", + (collection_id,), + ).fetchone() + return dict(row) if row else None + + +def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]: + """Collections in a project, the default first then by name (§22.5). `unlisted` + is omitted from enumeration unless include_unlisted (a direct-id read or the + corpus mirror, which serves every collection).""" + rows = db.conn().execute( + "SELECT id, project_id, type, subfolder, initial_state, visibility, name " + "FROM collections WHERE project_id = ? ORDER BY (id != 'default'), name, id", + (project_id,), + ).fetchall() + out: list[dict] = [] + for r in rows: + if not include_unlisted and r["visibility"] == "unlisted": + continue + out.append(dict(r)) + return out 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..b4e9826 100644 --- a/backend/app/registry.py +++ b/backend/app/registry.py @@ -55,6 +55,17 @@ class ProjectEntry: config: dict = field(default_factory=dict) # theme, enabled_models +@dataclass +class CollectionEntry: + """A named collection declared by a `.collection.yaml` manifest inside a + project's content repo (S2). `visibility=None` means "inherit the project's + visibility".""" + type: str + visibility: str | None + initial_state: str + name: str | None + + @dataclass class RegistryDoc: deployment_name: str @@ -114,44 +125,101 @@ def parse_registry(text: str) -> RegistryDoc: ) -def apply_registry(doc: RegistryDoc, registry_sha: str) -> None: - """Upsert the parsed registry into projects + deployment. Idempotent. +def parse_collection_manifest(text: str) -> CollectionEntry: + """Parse + validate a `.collection.yaml`. Pure (no I/O). Raises RegistryError. - §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). + `type` is required and immutable (§22.4a, enforced at upsert). `visibility` + is optional — omitted means inherit the project's. `initial_state` defaults + per type (§22.4b).""" + raw = yaml.safe_load(text) or {} + if not isinstance(raw, dict): + raise RegistryError("collection manifest must be a mapping") + ctype = str(raw.get("type") or "").strip() + if ctype not in VALID_TYPES: + raise RegistryError(f"collection has invalid type {ctype!r}") + vis = raw.get("visibility") + if vis is not None: + vis = str(vis).strip() + if vis not in VALID_VISIBILITY: + raise RegistryError(f"collection has invalid visibility {vis!r}") + initial_state = str( + raw.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ctype] + ).strip() + if initial_state not in VALID_INITIAL_STATE: + raise RegistryError(f"collection has invalid initial_state {initial_state!r}") + name = raw.get("name") + name = str(name).strip() if name else None + return CollectionEntry(ctype, vis, initial_state, name) + + +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 + + +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( """ @@ -163,6 +231,88 @@ def apply_registry(doc: RegistryDoc, registry_sha: str) -> None: ) +def _strictest_visibility(a: str, b: str) -> str: + """The stricter of two §22.5 visibilities on the public-exposure axis + (`public` < `unlisted` < `gated`). Used to enforce that a collection is set + only as strict or stricter than its project (S3 operator decision).""" + rank = {"public": 0, "unlisted": 1, "gated": 2} + return a if rank.get(a, 2) >= rank.get(b, 2) else b + + +def _upsert_named_collection( + proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str +) -> None: + """Upsert one named collection (S2). Type is immutable (§22.4a): a type + change against an existing row is refused (logged, not applied). A None + manifest visibility inherits the project's visibility; a manifest that tries + to be *looser* than its project is clamped to the project's (S3 strictness: + a collection may narrow but never widen its project's visibility).""" + requested = ce.visibility or proj.visibility + visibility = _strictest_visibility(requested, proj.visibility) + if visibility != requested: + log.warning( + "registry: collection %s visibility %r looser than project %s %r — " + "clamped to %r (S3 strictness)", + subdir, requested, proj.id, proj.visibility, visibility, + ) + with db.tx() as conn: + existing = conn.execute( + "SELECT type FROM collections WHERE id = ?", (subdir,) + ).fetchone() + if existing is not None and existing["type"] != ce.type: + log.error( + "registry: refusing immutable type change on collection %s (%s -> %s)", + subdir, existing["type"], ce.type, + ) + return + 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, + initial_state = excluded.initial_state, + visibility = excluded.visibility, + name = excluded.name, + registry_sha = excluded.registry_sha, + updated_at = datetime('now') + """, + (subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, sha), + ) + + +async def _mirror_named_collections(config: Config, gitea: Gitea, doc: RegistryDoc, sha: str) -> None: + """§22 S2: named collections are declared by `.collection.yaml` manifests + inside each project's content repo (the default collection comes from + projects.yaml). Walk each content repo root; a subdir carrying a manifest + becomes a collection keyed by the subdir name. Tolerant: a transport or + parse failure on one project/collection logs and is skipped, never aborts + the wider mirror (keep last-good).""" + for proj in doc.projects: + try: + items = await gitea.list_dir(config.gitea_org, proj.content_repo, "", ref="main") + except Exception as e: # noqa: BLE001 — GiteaError/transport: tolerate + log.warning("registry: cannot list %s root: %s", proj.content_repo, e) + continue + for it in items: + if it.get("type") != "dir": + continue + subdir = it["name"] + manifest = await gitea.get_contents( + config.gitea_org, proj.content_repo, f"{subdir}/.collection.yaml", ref="main" + ) + if not manifest or manifest.get("type") != "file": + continue + mtext = base64.b64decode(manifest["content"]).decode("utf-8") + try: + ce = parse_collection_manifest(mtext) + except RegistryError as e: + log.error("registry: bad manifest %s/%s: %s", proj.content_repo, subdir, e) + continue + _upsert_named_collection(proj, subdir, ce, sha) + + async def refresh_registry(config: Config, gitea: Gitea) -> None: """Mirror REGISTRY_REPO/projects.yaml into projects + deployment. @@ -181,5 +331,8 @@ 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)) + # §22 S2: discover + upsert named collections from each content repo. + await _mirror_named_collections(config, gitea, doc, sha) log.info("registry: mirrored %d project(s) at %s", len(doc.projects), sha) diff --git a/backend/migrations/029_collections.sql b/backend/migrations/029_collections.sql new file mode 100644 index 0000000..bde568f --- /dev/null +++ b/backend/migrations/029_collections.sql @@ -0,0 +1,427 @@ +-- migrate:no-foreign-keys +-- +-- §22 three-tier refactor — S1. Insert a *collection* grain beneath project. +-- +-- (1) a `collections` table beneath `projects`; +-- (2) move the per-corpus fields (type, initial_state) down from `projects` +-- (projects keeps id, name, content_repo, visibility, config_json, …); +-- (3) one default collection per project (id='default' for the standard +-- single-project deployment, subfolder = repo root), inheriting the +-- project's type / initial_state / visibility; +-- (4) re-key the 13 entry-corpus tables (project_id, slug) -> (collection_id, +-- slug) via the migration-028 rebuild pattern, mapping each row to its +-- project's default collection by JOIN; +-- (5) generalise project_members -> memberships(scope_type ∈ {project, +-- collection}, scope_id, …), collapsing the role enum to {owner, +-- contributor} (§B.3). +-- +-- SQLite can't ALTER a PK/UNIQUE in place, so each keyed table is rebuilt by the +-- official create-copy-drop-rename procedure. FK enforcement is OFF for the file +-- (the `migrate:no-foreign-keys` marker tells the runner to toggle it and run +-- foreign_key_check after). cached_rfcs is rebuilt FIRST so the child tables can +-- re-point their composite FK at its new (collection_id, slug) key. +-- +-- The tables 026 tagged with project_id but 028 did NOT key (threads, changes, +-- notifications, actions, pr_resolution_branches, cached_prs) keep project_id — +-- they carry a project-grain tag, untouched in S1. See +-- docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E. + +-- ── collections: the new typed-corpus grain beneath projects ─────────────── +CREATE TABLE collections ( + id TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'document' + CHECK (type IN ('document', 'specification', 'bdd')), + subfolder TEXT NOT NULL DEFAULT '', + initial_state TEXT NOT NULL DEFAULT 'super-draft' + CHECK (initial_state IN ('super-draft', 'active')), + visibility TEXT NOT NULL DEFAULT 'gated' + CHECK (visibility IN ('gated', 'public', 'unlisted')), + name TEXT, + registry_sha TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (id) +); +CREATE INDEX idx_collections_project ON collections(project_id); + +-- One default collection per project. id='default' for the standard +-- single-project deployment (a stable literal across deploy histories); the +-- project_id is used as a unique fallback id only if a non-standard +-- multi-project deployment migrates (pre-S5; avoids a PK collision). +INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) +SELECT + CASE WHEN (SELECT COUNT(*) FROM projects) <= 1 THEN 'default' ELSE p.id END, + p.id, p.type, '', p.initial_state, p.visibility, p.name +FROM projects p; + +-- ── projects: rebuild to DROP the per-corpus fields (type, initial_state) ─── +CREATE TABLE projects__new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + content_repo TEXT, + visibility TEXT NOT NULL DEFAULT 'gated' + CHECK (visibility IN ('gated', 'public', 'unlisted')), + config_json TEXT, + registry_sha TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT INTO projects__new (id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at) +SELECT id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at FROM projects; +DROP TABLE projects; +ALTER TABLE projects__new RENAME TO projects; + +-- ── cached_rfcs: PRIMARY KEY (project_id, slug) -> (collection_id, slug) ──── +CREATE TABLE cached_rfcs__new ( + slug TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')), + rfc_id TEXT, + repo TEXT, + proposed_by TEXT, + proposed_at TEXT, + graduated_at TEXT, + graduated_by TEXT, + owners_json TEXT NOT NULL DEFAULT '[]', + arbiters_json TEXT NOT NULL DEFAULT '[]', + tags_json TEXT NOT NULL DEFAULT '[]', + body TEXT, + body_sha TEXT, + last_main_commit_at TEXT, + last_entry_commit_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + models_json TEXT, + funder_login TEXT, + proposed_use_case TEXT, + collection_id TEXT NOT NULL DEFAULT 'default' REFERENCES collections(id), + unreviewed INTEGER NOT NULL DEFAULT 0, + reviewed_at TEXT, + reviewed_by TEXT, + PRIMARY KEY (collection_id, slug) +); +INSERT INTO cached_rfcs__new + (slug, title, state, rfc_id, repo, proposed_by, proposed_at, graduated_at, + graduated_by, owners_json, arbiters_json, tags_json, body, body_sha, + last_main_commit_at, last_entry_commit_at, updated_at, models_json, + funder_login, proposed_use_case, collection_id, unreviewed, reviewed_at, reviewed_by) +SELECT + r.slug, r.title, r.state, r.rfc_id, r.repo, r.proposed_by, r.proposed_at, r.graduated_at, + r.graduated_by, r.owners_json, r.arbiters_json, r.tags_json, r.body, r.body_sha, + r.last_main_commit_at, r.last_entry_commit_at, r.updated_at, r.models_json, + r.funder_login, r.proposed_use_case, + (SELECT c.id FROM collections c WHERE c.project_id = r.project_id LIMIT 1), + r.unreviewed, r.reviewed_at, r.reviewed_by +FROM cached_rfcs r; +DROP TABLE cached_rfcs; +ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs; +CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state); +CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs ( + COALESCE(last_main_commit_at, last_entry_commit_at) DESC +); +CREATE INDEX idx_cached_rfcs_collection ON cached_rfcs(collection_id); + +-- ── rfc_invitations: single-col FK -> composite (collection_id, rfc_slug) ─── +CREATE TABLE rfc_invitations__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + inviter_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + invitee_email TEXT NOT NULL, + role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')), + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')), + token TEXT NOT NULL, + expires_at TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + accepted_at TEXT, + accepted_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + collection_id TEXT NOT NULL DEFAULT 'default', + FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE +); +INSERT INTO rfc_invitations__new + (id, rfc_slug, inviter_user_id, invitee_email, role_in_rfc, status, token, + expires_at, created_at, accepted_at, accepted_by_user_id, collection_id) +SELECT + i.id, i.rfc_slug, i.inviter_user_id, i.invitee_email, i.role_in_rfc, i.status, i.token, + i.expires_at, i.created_at, i.accepted_at, i.accepted_by_user_id, + (SELECT c.id FROM collections c WHERE c.project_id = i.project_id LIMIT 1) +FROM rfc_invitations i; +DROP TABLE rfc_invitations; +ALTER TABLE rfc_invitations__new RENAME TO rfc_invitations; +CREATE UNIQUE INDEX idx_rfc_invitations_token ON rfc_invitations (token); +CREATE INDEX idx_rfc_invitations_rfc_status ON rfc_invitations (rfc_slug, status); +CREATE INDEX idx_rfc_invitations_email_status ON rfc_invitations (invitee_email, status); + +-- ── cached_branches: UNIQUE (project_id, rfc_slug, branch_name) -> collection +CREATE TABLE cached_branches__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + head_sha TEXT, + state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open', 'closed', 'deleted')), + pinned INTEGER NOT NULL DEFAULT 0, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + last_commit_at TEXT, + closed_at TEXT, + collection_id TEXT NOT NULL DEFAULT 'default', + UNIQUE (collection_id, rfc_slug, branch_name) +); +INSERT INTO cached_branches__new + (id, rfc_slug, branch_name, head_sha, state, pinned, created_at, last_commit_at, closed_at, collection_id) +SELECT + b.id, b.rfc_slug, b.branch_name, b.head_sha, b.state, b.pinned, b.created_at, b.last_commit_at, b.closed_at, + (SELECT c.id FROM collections c WHERE c.project_id = b.project_id LIMIT 1) +FROM cached_branches b; +DROP TABLE cached_branches; +ALTER TABLE cached_branches__new RENAME TO cached_branches; +CREATE INDEX idx_cached_branches_rfc ON cached_branches (rfc_slug, state); + +-- ── branch_visibility: UNIQUE (project_id, rfc_slug, branch_name) -> collection +CREATE TABLE branch_visibility__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + read_public INTEGER NOT NULL DEFAULT 1, + contribute_mode TEXT NOT NULL DEFAULT 'just-me' CHECK (contribute_mode IN ('just-me', 'specific', 'any-contributor')), + collection_id TEXT NOT NULL DEFAULT 'default', + UNIQUE (collection_id, rfc_slug, branch_name) +); +INSERT INTO branch_visibility__new + (id, rfc_slug, branch_name, read_public, contribute_mode, collection_id) +SELECT + v.id, v.rfc_slug, v.branch_name, v.read_public, v.contribute_mode, + (SELECT c.id FROM collections c WHERE c.project_id = v.project_id LIMIT 1) +FROM branch_visibility v; +DROP TABLE branch_visibility; +ALTER TABLE branch_visibility__new RENAME TO branch_visibility; + +-- ── branch_contribute_grants: UNIQUE (..., grantee) -> +collection_id ─────── +CREATE TABLE branch_contribute_grants__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + grantee_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + granted_by INTEGER NOT NULL REFERENCES users(id) ON DELETE SET NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + collection_id TEXT NOT NULL DEFAULT 'default', + UNIQUE (collection_id, rfc_slug, branch_name, grantee_user_id) +); +INSERT INTO branch_contribute_grants__new + (id, rfc_slug, branch_name, grantee_user_id, granted_by, granted_at, collection_id) +SELECT + g.id, g.rfc_slug, g.branch_name, g.grantee_user_id, g.granted_by, g.granted_at, + (SELECT c.id FROM collections c WHERE c.project_id = g.project_id LIMIT 1) +FROM branch_contribute_grants g; +DROP TABLE branch_contribute_grants; +ALTER TABLE branch_contribute_grants__new RENAME TO branch_contribute_grants; +CREATE INDEX idx_grants_lookup ON branch_contribute_grants (rfc_slug, branch_name); +CREATE INDEX idx_grants_grantee ON branch_contribute_grants (grantee_user_id); + +-- ── stars: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ────────── +CREATE TABLE stars__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rfc_slug TEXT NOT NULL, + starred_at TEXT NOT NULL DEFAULT (datetime('now')), + collection_id TEXT NOT NULL DEFAULT 'default', + UNIQUE (collection_id, user_id, rfc_slug) +); +INSERT INTO stars__new (id, user_id, rfc_slug, starred_at, collection_id) +SELECT s.id, s.user_id, s.rfc_slug, s.starred_at, + (SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1) +FROM stars s; +DROP TABLE stars; +ALTER TABLE stars__new RENAME TO stars; +CREATE INDEX idx_stars_user ON stars (user_id); +CREATE INDEX idx_stars_rfc ON stars (rfc_slug); + +-- ── watches: UNIQUE (project_id, user_id, rfc_slug) -> collection_id ──────── +CREATE TABLE watches__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rfc_slug TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('watching', 'following', 'muted')), + set_by TEXT NOT NULL CHECK (set_by IN ('auto', 'explicit')), + set_at TEXT NOT NULL DEFAULT (datetime('now')), + last_participation_at TEXT, + collection_id TEXT NOT NULL DEFAULT 'default', + UNIQUE (collection_id, user_id, rfc_slug) +); +INSERT INTO watches__new + (id, user_id, rfc_slug, state, set_by, set_at, last_participation_at, collection_id) +SELECT + w.id, w.user_id, w.rfc_slug, w.state, w.set_by, w.set_at, w.last_participation_at, + (SELECT c.id FROM collections c WHERE c.project_id = w.project_id LIMIT 1) +FROM watches w; +DROP TABLE watches; +ALTER TABLE watches__new RENAME TO watches; +CREATE INDEX idx_watches_user ON watches (user_id); +CREATE INDEX idx_watches_rfc ON watches (rfc_slug); +CREATE INDEX idx_watches_decay ON watches (state, last_participation_at); + +-- ── pr_seen: UNIQUE (project_id, user_id, rfc_slug, pr_number) -> collection ─ +CREATE TABLE pr_seen__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rfc_slug TEXT NOT NULL, + pr_number INTEGER NOT NULL, + last_seen_commit_sha TEXT, + last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL, + seen_at TEXT NOT NULL DEFAULT (datetime('now')), + collection_id TEXT NOT NULL DEFAULT 'default', + UNIQUE (collection_id, user_id, rfc_slug, pr_number) +); +INSERT INTO pr_seen__new + (id, user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at, collection_id) +SELECT + p.id, p.user_id, p.rfc_slug, p.pr_number, p.last_seen_commit_sha, p.last_seen_message_id, p.seen_at, + (SELECT c.id FROM collections c WHERE c.project_id = p.project_id LIMIT 1) +FROM pr_seen p; +DROP TABLE pr_seen; +ALTER TABLE pr_seen__new RENAME TO pr_seen; + +-- ── branch_chat_seen: UNIQUE (project_id, user_id, rfc_slug, branch) -> coll ─ +CREATE TABLE branch_chat_seen__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + rfc_slug TEXT NOT NULL, + branch_name TEXT NOT NULL, + last_seen_message_id INTEGER REFERENCES thread_messages(id) ON DELETE SET NULL, + seen_at TEXT NOT NULL DEFAULT (datetime('now')), + collection_id TEXT NOT NULL DEFAULT 'default', + UNIQUE (collection_id, user_id, rfc_slug, branch_name) +); +INSERT INTO branch_chat_seen__new + (id, user_id, rfc_slug, branch_name, last_seen_message_id, seen_at, collection_id) +SELECT + s.id, s.user_id, s.rfc_slug, s.branch_name, s.last_seen_message_id, s.seen_at, + (SELECT c.id FROM collections c WHERE c.project_id = s.project_id LIMIT 1) +FROM branch_chat_seen s; +DROP TABLE branch_chat_seen; +ALTER TABLE branch_chat_seen__new RENAME TO branch_chat_seen; + +-- ── funder_consents: PRIMARY KEY (project_id, user_id, rfc_slug) -> collection +CREATE TABLE funder_consents__new ( + user_id INTEGER NOT NULL, + rfc_slug TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + collection_id TEXT NOT NULL DEFAULT 'default', + PRIMARY KEY (collection_id, user_id, rfc_slug), + FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE +); +INSERT INTO funder_consents__new (user_id, rfc_slug, created_at, collection_id) +SELECT f.user_id, f.rfc_slug, f.created_at, + (SELECT c.id FROM collections c WHERE c.project_id = f.project_id LIMIT 1) +FROM funder_consents f; +DROP TABLE funder_consents; +ALTER TABLE funder_consents__new RENAME TO funder_consents; +CREATE INDEX idx_funder_consents_slug ON funder_consents (rfc_slug); + +-- ── rfc_collaborators: UNIQUE idx + composite FK -> collection_id ─────────── +CREATE TABLE rfc_collaborators__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_in_rfc TEXT NOT NULL CHECK (role_in_rfc IN ('contributor', 'discussant')), + invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + collection_id TEXT NOT NULL DEFAULT 'default', + FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE +); +INSERT INTO rfc_collaborators__new + (id, rfc_slug, user_id, role_in_rfc, invitation_id, created_at, collection_id) +SELECT + rc.id, rc.rfc_slug, rc.user_id, rc.role_in_rfc, rc.invitation_id, rc.created_at, + (SELECT c.id FROM collections c WHERE c.project_id = rc.project_id LIMIT 1) +FROM rfc_collaborators rc; +DROP TABLE rfc_collaborators; +ALTER TABLE rfc_collaborators__new RENAME TO rfc_collaborators; +CREATE UNIQUE INDEX idx_rfc_collaborators_unique ON rfc_collaborators (collection_id, rfc_slug, user_id); +CREATE INDEX idx_rfc_collaborators_user ON rfc_collaborators (user_id); + +-- ── contribution_requests: UNIQUE idx (pending) + composite FK -> collection ─ +CREATE TABLE contribution_requests__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + rfc_slug TEXT NOT NULL, + requester_user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + matched_term TEXT NOT NULL, + who_i_am TEXT NOT NULL, + why TEXT NOT NULL, + use_case TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'accepted', 'declined')), + created_at TEXT NOT NULL DEFAULT (datetime('now')), + decided_at TEXT, + decided_by_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, + invitation_id INTEGER REFERENCES rfc_invitations(id) ON DELETE SET NULL, + notification_id INTEGER REFERENCES notifications(id) ON DELETE SET NULL, + collection_id TEXT NOT NULL DEFAULT 'default', + FOREIGN KEY (collection_id, rfc_slug) REFERENCES cached_rfcs(collection_id, slug) ON DELETE CASCADE +); +INSERT INTO contribution_requests__new + (id, rfc_slug, requester_user_id, matched_term, who_i_am, why, use_case, status, + created_at, decided_at, decided_by_user_id, invitation_id, notification_id, collection_id) +SELECT + cr.id, cr.rfc_slug, cr.requester_user_id, cr.matched_term, cr.who_i_am, cr.why, cr.use_case, cr.status, + cr.created_at, cr.decided_at, cr.decided_by_user_id, cr.invitation_id, cr.notification_id, + (SELECT c.id FROM collections c WHERE c.project_id = cr.project_id LIMIT 1) +FROM contribution_requests cr; +DROP TABLE contribution_requests; +ALTER TABLE contribution_requests__new RENAME TO contribution_requests; +CREATE INDEX idx_contribution_requests_rfc ON contribution_requests(rfc_slug, status); +CREATE INDEX idx_contribution_requests_requester ON contribution_requests(requester_user_id, status); +CREATE UNIQUE INDEX idx_contribution_requests_one_open + ON contribution_requests(collection_id, rfc_slug, requester_user_id) + WHERE status = 'pending'; + +-- ── proposed_use_cases: UNIQUE (project_id, scope, pr_number) -> collection ── +CREATE TABLE proposed_use_cases__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope TEXT NOT NULL CHECK (scope IN ('rfc', 'pr')), + rfc_slug TEXT NOT NULL, + pr_number INTEGER NOT NULL, + use_case TEXT NOT NULL, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + collection_id TEXT NOT NULL DEFAULT 'default', + UNIQUE (collection_id, scope, pr_number) +); +INSERT INTO proposed_use_cases__new + (id, scope, rfc_slug, pr_number, use_case, created_at, collection_id) +SELECT + u.id, u.scope, u.rfc_slug, u.pr_number, u.use_case, u.created_at, + (SELECT c.id FROM collections c WHERE c.project_id = u.project_id LIMIT 1) +FROM proposed_use_cases u; +DROP TABLE proposed_use_cases; +ALTER TABLE proposed_use_cases__new RENAME TO proposed_use_cases; +CREATE INDEX idx_proposed_use_cases_lookup ON proposed_use_cases (scope, pr_number); +CREATE INDEX idx_proposed_use_cases_slug ON proposed_use_cases (scope, rfc_slug); + +-- ── project_members -> memberships(scope_type, scope_id, …); roles collapsed ─ +CREATE TABLE memberships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL CHECK (scope_type IN ('project', 'collection')), + scope_id TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')), + granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (scope_type, scope_id, user_id) +); +CREATE INDEX idx_memberships_user ON memberships(user_id); +CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id); + +-- M2 project_members rows attached at what is now the *collection*; collapse the +-- role enum (project_admin -> owner, project_contributor -> contributor; +-- project_viewer dropped this pass, §B.3) and migrate onto the default +-- collection of each project. +INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by, granted_at) +SELECT 'collection', + (SELECT c.id FROM collections c WHERE c.project_id = pm.project_id LIMIT 1), + pm.user_id, + CASE pm.role WHEN 'project_admin' THEN 'owner' + WHEN 'project_contributor' THEN 'contributor' + ELSE 'contributor' END, + pm.granted_by, pm.granted_at +FROM project_members pm +WHERE pm.role IN ('project_admin', 'project_contributor'); +DROP TABLE project_members; diff --git a/backend/migrations/030_global_scope.sql b/backend/migrations/030_global_scope.sql new file mode 100644 index 0000000..b2c0c6d --- /dev/null +++ b/backend/migrations/030_global_scope.sql @@ -0,0 +1,34 @@ +-- migrate:no-foreign-keys +-- +-- §22 three-tier — S3. Admit a *global*-scope grant to the memberships table. +-- +-- §B.2's resolver folds four layers (global → project → collection → per-entry). +-- Migration 029 created `memberships` with scope_type ∈ {project, collection} +-- only; the global tier was left to S3. A global grant is how a "global RFC +-- Contributor" (a contributor who may propose in every collection of every +-- project, distinct from a deployment owner/admin) is represented — see +-- docs/design/2026-06-05-three-tier-projects-collections.md §B.2/§B.3 and the +-- C.1 "cleo" scenario. +-- +-- SQLite can't ALTER a CHECK constraint in place, so the table is rebuilt by the +-- create-copy-drop-rename procedure (the 028/029 pattern). The global scope uses +-- a stable sentinel scope_id of '*' (one global tier per deployment); the +-- UNIQUE(scope_type, scope_id, user_id) then admits exactly one global grant per +-- user, mirroring the project/collection rows. + +CREATE TABLE memberships__new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL CHECK (scope_type IN ('global', 'project', 'collection')), + scope_id TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')), + granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (scope_type, scope_id, user_id) +); +INSERT INTO memberships__new (id, scope_type, scope_id, user_id, role, granted_by, granted_at) +SELECT id, scope_type, scope_id, user_id, role, granted_by, granted_at FROM memberships; +DROP TABLE memberships; +ALTER TABLE memberships__new RENAME TO memberships; +CREATE INDEX idx_memberships_user ON memberships(user_id); +CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id); 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_collection_create_vertical.py b/backend/tests/test_collection_create_vertical.py new file mode 100644 index 0000000..c19a978 --- /dev/null +++ b/backend/tests/test_collection_create_vertical.py @@ -0,0 +1,83 @@ +"""§22 S2 — create-collection vertical: a deployment owner/admin POSTs, the bot +commits a `.collection.yaml`, and the registry mirror upserts the collections +row (registry stays the source of truth).""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from app import db +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, +) + + +def test_create_collection_commits_manifest_and_mirrors(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + 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", email="ben@test") + r = client.post("/api/projects/default/collections", + json={"collection_id": "features", "type": "bdd", "name": "Features"}) + assert r.status_code == 200, r.text + assert r.json()["type"] == "bdd" + + # The bot committed the manifest to the content repo's main. + f = fake.files.get(("wiggleverse", "meta", "main", "features/.collection.yaml")) + assert f is not None + assert "type: bdd" in f["content"] + + # The registry refresh mirrored it into a collections row. + row = db.conn().execute( + "SELECT type, project_id, subfolder FROM collections WHERE id='features'" + ).fetchone() + assert (row["type"], row["project_id"], row["subfolder"]) == ("bdd", "default", "features") + + # It is now navigable via the directory + scoped serve. + items = client.get("/api/projects/default/collections").json()["items"] + assert any(c["id"] == "features" for c in items) + assert client.get("/api/projects/default/collections/features/rfcs").status_code == 200 + + +def test_create_collection_requires_admin(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="alice", role="contributor") + sign_in_as(client, user_id=2, gitea_login="alice", display_name="Alice", + role="contributor", email="alice@test") + r = client.post("/api/projects/default/collections", + json={"collection_id": "x", "type": "bdd"}) + assert r.status_code in (401, 403) + + +def test_create_collection_anonymous_rejected(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + r = client.post("/api/projects/default/collections", + json={"collection_id": "x", "type": "bdd"}) + assert r.status_code in (401, 403) + + +def test_create_collection_rejects_duplicate(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + 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", email="ben@test") + ok = client.post("/api/projects/default/collections", + json={"collection_id": "features", "type": "bdd"}) + assert ok.status_code == 200, ok.text + dup = client.post("/api/projects/default/collections", + json={"collection_id": "features", "type": "bdd"}) + assert dup.status_code == 409 + + +def test_create_collection_rejects_reserved_default_id(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + 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", email="ben@test") + r = client.post("/api/projects/default/collections", + json={"collection_id": "default", "type": "bdd"}) + assert r.status_code == 422 diff --git a/backend/tests/test_collection_helpers.py b/backend/tests/test_collection_helpers.py new file mode 100644 index 0000000..77c0e02 --- /dev/null +++ b/backend/tests/test_collection_helpers.py @@ -0,0 +1,64 @@ +"""§22 S2 — collection read helpers: list_collections / get_collection / +subfolder_of.""" +from __future__ import annotations + +import tempfile +from pathlib import Path + +from app import collections as collections_mod, db +from app.config import Config + + +def _db() -> Config: + cfg = Config( + gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="x", + registry_repo="registry", oauth_client_id="x", + oauth_client_secret="x", app_url="x", secret_key="x", + database_path=Path(tempfile.mkdtemp(prefix="colhelp-")) / "t.db", + owner_gitea_login="x", webhook_secret="x", + ) + db.run_migrations(cfg) + if db._CONN is not None: + db._CONN.close() + db._CONN = None + db.init(cfg) + return cfg + + +def _seed(project_id="ohm"): + db.conn().execute( + "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) " + "VALUES (?, 'Ohm', 'ohm-rfc', 'public', datetime('now'))", (project_id,)) + for cid, sub, vis, name in [ + ("default", "", "public", "Model"), + ("features", "features", "public", "Features"), + ("secret", "secret", "unlisted", "Secret"), + ]: + db.conn().execute( + "INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, " + "visibility, name, created_at, updated_at) VALUES (?,?, 'document', ?, " + "'super-draft', ?, ?, datetime('now'), datetime('now'))", + (cid, project_id, sub, vis, name)) + + +def test_list_collections_excludes_unlisted(): + _db() + _seed() + ids = [c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=False)] + assert ids == ["default", "features"] # default first, then by name; 'secret' omitted + + +def test_list_collections_include_unlisted(): + _db() + _seed() + ids = {c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=True)} + assert ids == {"default", "features", "secret"} + + +def test_get_collection_and_subfolder(): + _db() + _seed() + assert collections_mod.get_collection("features")["name"] == "Features" + assert collections_mod.subfolder_of("features") == "features" + assert collections_mod.subfolder_of("default") == "" + assert collections_mod.get_collection("nope") is None diff --git a/backend/tests/test_collection_registry.py b/backend/tests/test_collection_registry.py new file mode 100644 index 0000000..fa8a403 --- /dev/null +++ b/backend/tests/test_collection_registry.py @@ -0,0 +1,146 @@ +"""§22 S2 — the registry mirror reads `.collection.yaml` manifests inside each +project's content repo and upserts a named collection per manifest. The default +collection still flows from projects.yaml (test_registry.py).""" +from __future__ import annotations + +import asyncio +import base64 +import tempfile +from pathlib import Path + +import pytest + +from app import db, registry +from app.config import Config + + +def _db() -> Config: + cfg = Config( + gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse", + registry_repo="registry", oauth_client_id="x", + oauth_client_secret="x", app_url="x", secret_key="x", + database_path=Path(tempfile.mkdtemp(prefix="colreg-")) / "t.db", + owner_gitea_login="x", webhook_secret="x", + ) + db.run_migrations(cfg) + if db._CONN is not None: + db._CONN.close() + db._CONN = None + db.init(cfg) + return cfg + + +# --- pure parser -------------------------------------------------------------- + + +def test_parse_collection_manifest_minimal(): + doc = registry.parse_collection_manifest("type: bdd\n") + assert doc.type == "bdd" + # §22.4b: bdd defaults to 'active'; visibility inherits (None == inherit). + assert doc.initial_state == "active" + assert doc.visibility is None + assert doc.name is None + + +def test_parse_collection_manifest_full(): + doc = registry.parse_collection_manifest( + "type: document\nvisibility: public\ninitial_state: active\nname: Model\n" + ) + assert (doc.type, doc.visibility, doc.initial_state, doc.name) == ( + "document", "public", "active", "Model", + ) + + +def test_parse_collection_manifest_rejects_bad_type(): + with pytest.raises(registry.RegistryError): + registry.parse_collection_manifest("type: nonsense\n") + + +def test_parse_collection_manifest_rejects_bad_visibility(): + with pytest.raises(registry.RegistryError): + registry.parse_collection_manifest("type: bdd\nvisibility: nope\n") + + +# --- mirror discovery --------------------------------------------------------- + + +class _FakeGitea: + """Minimal Gitea stub: projects.yaml in the registry repo + a content repo + whose root holds a `features/` subdir carrying a `.collection.yaml`.""" + + def __init__(self, projects_yaml: str, repo_tree: dict[str, dict[str, str]]): + self._projects_yaml = projects_yaml + self._repo_tree = repo_tree # {repo: {path: text}} + + async def get_contents(self, org, repo, path, ref="main"): + if path == "projects.yaml": + return {"type": "file", + "content": base64.b64encode(self._projects_yaml.encode()).decode(), + "sha": "regsha-test"} + text = self._repo_tree.get(repo, {}).get(path) + if text is None: + return None + return {"type": "file", + "content": base64.b64encode(text.encode()).decode(), "sha": "c0ffee"} + + async def list_dir(self, org, repo, path, ref="main"): + # Root listing: surface each top-level segment as a 'dir' entry. + prefix = (path.rstrip("/") + "/") if path else "" + dirs = set() + for p in self._repo_tree.get(repo, {}): + if not p.startswith(prefix): + continue + rest = p[len(prefix):] + if "/" in rest: + dirs.add(rest.split("/", 1)[0]) + return [{"type": "dir", "name": n, "path": prefix + n} for n in sorted(dirs)] + + +_PROJECTS = ( + "deployment:\n name: Ohm\n tagline: t\n" + "projects:\n - id: ohm\n name: Ohm\n type: document\n" + " content_repo: ohm-rfc\n visibility: public\n" +) + + +def test_refresh_registry_mirrors_named_collection(): + cfg = _db() + gitea = _FakeGitea( + projects_yaml=_PROJECTS, + repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\nname: Features\n"}}, + ) + asyncio.run(registry.refresh_registry(cfg, gitea)) + row = db.conn().execute( + "SELECT type, subfolder, name, project_id, visibility FROM collections WHERE id='features'" + ).fetchone() + assert row is not None + assert (row["type"], row["subfolder"], row["project_id"]) == ("bdd", "features", "ohm") + assert row["name"] == "Features" + # visibility inherits the project's (public) when the manifest omits it. + assert row["visibility"] == "public" + + +def test_refresh_registry_leaves_default_collection_intact(): + cfg = _db() + gitea = _FakeGitea( + projects_yaml=_PROJECTS, + repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}}, + ) + asyncio.run(registry.refresh_registry(cfg, gitea)) + # The default collection (from projects.yaml) and the named one coexist. + ids = {r["id"] for r in db.conn().execute("SELECT id FROM collections")} + assert {"default", "features"} <= ids + + +def test_refresh_registry_immutable_type_on_named_collection(): + cfg = _db() + gitea = _FakeGitea( + projects_yaml=_PROJECTS, + repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\n"}}, + ) + asyncio.run(registry.refresh_registry(cfg, gitea)) + # A later manifest that flips the type is refused (§22.4a immutable type). + gitea._repo_tree["ohm-rfc"]["features/.collection.yaml"] = "type: document\n" + asyncio.run(registry.refresh_registry(cfg, gitea)) + t = db.conn().execute("SELECT type FROM collections WHERE id='features'").fetchone()["type"] + assert t == "bdd" diff --git a/backend/tests/test_collection_scoped_serve.py b/backend/tests/test_collection_scoped_serve.py new file mode 100644 index 0000000..443cad8 --- /dev/null +++ b/backend/tests/test_collection_scoped_serve.py @@ -0,0 +1,164 @@ +"""§22 S2 — collection-grained corpus mirror + collection-scoped serve/propose. + +The mirror test drives cache.refresh_meta_repo against an in-memory content repo +holding entries under both the default `rfcs/` and a named collection's +`features/rfcs/`, and asserts cached_rfcs is keyed by the right collection_id.""" +from __future__ import annotations + +import asyncio +import tempfile +from pathlib import Path + +from app import cache, db +from app.config import Config + + +def _db() -> Config: + cfg = Config( + gitea_url="x", gitea_bot_user="x", gitea_bot_token="x", gitea_org="wiggleverse", + registry_repo="registry", oauth_client_id="x", + oauth_client_secret="x", app_url="x", secret_key="x", + database_path=Path(tempfile.mkdtemp(prefix="colserve-")) / "t.db", + owner_gitea_login="x", webhook_secret="x", + ) + db.run_migrations(cfg) + if db._CONN is not None: + db._CONN.close() + db._CONN = None + db.init(cfg) + return cfg + + +class _CorpusGitea: + """A content repo modelled as a flat {path: text} map, listing files under a + directory prefix and reading them back.""" + + def __init__(self, tree: dict[str, str]): + self._tree = tree + + async def list_dir(self, org, repo, path, ref="main"): + out = [] + prefix = (path.rstrip("/") + "/") if path else "" + for p in self._tree: + if p.startswith(prefix) and "/" not in p[len(prefix):]: + out.append({"type": "file", "name": p.split("/")[-1], "path": p}) + return out + + async def read_file(self, org, repo, path, ref="main"): + t = self._tree.get(path) + return (t, "sha-" + path) if t is not None else None + + +def _entry_md(slug, title): + return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n" + + +def _seed_project_with_two_collections(): + db.conn().execute( + "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) " + "VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))") + for cid, sub in [("default", ""), ("features", "features")]: + db.conn().execute( + "INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, " + "visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, " + "'super-draft','public', datetime('now'), datetime('now'))", (cid, sub)) + + +def test_mirror_keys_entries_by_collection(): + cfg = _db() + _seed_project_with_two_collections() + gitea = _CorpusGitea({ + "rfcs/a.md": _entry_md("a", "Default A"), + "features/rfcs/b.md": _entry_md("b", "Feature B"), + }) + asyncio.run(cache.refresh_meta_repo(cfg, gitea)) + got = {(r["collection_id"], r["slug"]) for r in + db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")} + assert got == {("default", "a"), ("features", "b")} + + +# --- collection-scoped serve + propose (full app) ----------------------------- + +from fastapi.testclient import TestClient # noqa: E402 +from test_propose_vertical import ( # noqa: E402,F401 + app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as, +) + + +def _add_features_collection(content_repo="meta"): + """Add a named 'features' collection (subfolder 'features') under the seeded + default project, plus a single entry under features/rfcs/ in the db cache.""" + db.conn().execute( + "INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, " + "initial_state, visibility, name, created_at, updated_at) VALUES " + "('features','default','document','features','super-draft','public','Features', " + "datetime('now'), datetime('now'))") + + +def test_scoped_list_returns_only_that_collection(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _add_features_collection() + # Seed one entry under each collection's rfcs dir + mirror them in. + fake.files[("wiggleverse", "meta", "main", "rfcs/a.md")] = { + "content": _entry_md("a", "Default A"), "sha": "sa"} + fake.files[("wiggleverse", "meta", "main", "features/rfcs/b.md")] = { + "content": _entry_md("b", "Feature B"), "sha": "sb"} + from app import cache as cache_mod, gitea as gitea_mod + from app.config import load_config + cfg = load_config() + asyncio.run(cache_mod.refresh_meta_repo(cfg, gitea_mod.Gitea(cfg))) + + r = client.get("/api/projects/default/collections/features/rfcs") + assert r.status_code == 200, r.text + assert [i["slug"] for i in r.json()["items"]] == ["b"] + # The default collection still serves only its own entry. + r2 = client.get("/api/projects/default/collections/default/rfcs") + assert [i["slug"] for i in r2.json()["items"]] == ["a"] + + +def test_scoped_propose_writes_into_collection_subfolder(app_with_fake_gitea): + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _add_features_collection() + provision_user_row(user_id=3, login="alice", role="contributor") + # §22 S3: an explicitly-created collection requires an explicit scope + # grant to write (the grandfathered baseline covers only `default`). + db.conn().execute( + "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('collection', 'features', 3, 'contributor')") + sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", + role="contributor", email="alice@test") + r = client.post( + "/api/projects/default/collections/features/rfcs/propose", + json={"title": "New B", "slug": "newb", "pitch": "x", "tags": []}) + assert r.status_code == 200, r.text + # The bot wrote the entry under features/rfcs/, not rfcs/. + keys = {(k[1], k[3]) for k in fake.files + if k[1] == "meta" and k[3].endswith("newb.md")} + assert ("meta", "features/rfcs/newb.md") in keys + + +def test_scoped_routes_404_for_collection_outside_project(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + r = client.get("/api/projects/default/collections/nope/rfcs") + assert r.status_code == 404 + + +def test_s2_anonymous_empty_public_collection(app_with_fake_gitea): + """C3.6 (@S2): a public collection with no entries; an anonymous visitor + lands on its catalog → an empty catalog (200, no items), and the propose + action is not available to them (the propose route rejects anonymous).""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _add_features_collection() # public, no entries + # Anonymous (no session cookie) reads the empty catalog — 200, []. + r = client.get("/api/projects/default/collections/features/rfcs") + assert r.status_code == 200, r.text + assert r.json()["items"] == [] + # No propose action for an anonymous visitor. + r2 = client.post( + "/api/projects/default/collections/features/rfcs/propose", + json={"title": "X", "slug": "x", "pitch": "p", "tags": []}) + assert r2.status_code == 401 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_migration_029_collections.py b/backend/tests/test_migration_029_collections.py new file mode 100644 index 0000000..ab3f8bb --- /dev/null +++ b/backend/tests/test_migration_029_collections.py @@ -0,0 +1,139 @@ +"""Migration 029 — the collection grain beneath projects (§22 three-tier S1). + +Proves: a `collections` table exists with one default collection per project +(id='default', subfolder=repo root); the per-corpus fields (type, initial_state) +moved off `projects`; the 13 entry-corpus tables re-key (project_id,slug) -> +(collection_id,slug) with the composite PK/FK enforced; and project_members +generalises into memberships(scope_type, …) with the role enum collapsed. +Template: test_migration_028_project_scoped_keys.py. +""" +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 test_collections_table_exists_with_default_per_project(): + conn = _fresh_db() + cols = {r["name"] for r in conn.execute("PRAGMA table_info(collections)")} + assert {"id", "project_id", "type", "subfolder", + "initial_state", "visibility", "name", "registry_sha"} <= cols + # one default collection seeded for the bootstrap 'default' project (026) + row = conn.execute( + "SELECT id, project_id, subfolder FROM collections WHERE project_id='default'" + ).fetchone() + assert row is not None + assert row["id"] == "default" + assert row["subfolder"] == "" # repo root + + +def test_per_corpus_fields_moved_off_projects(): + conn = _fresh_db() + proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")} + assert "type" not in proj_cols + assert "initial_state" not in proj_cols + # projects keeps the grouping-tier fields + assert {"id", "name", "content_repo", "visibility"} <= proj_cols + + +def test_entry_tables_rekeyed_to_collection_id(): + conn = _fresh_db() + for t in ("cached_rfcs", "cached_branches", "stars", "watches", + "rfc_collaborators", "contribution_requests", "proposed_use_cases", + "branch_visibility", "branch_contribute_grants", "pr_seen", + "branch_chat_seen", "funder_consents", "rfc_invitations"): + cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({t})")} + assert "collection_id" in cols, f"{t} missing collection_id" + assert "project_id" not in cols, f"{t} still has project_id" + + +def test_cached_rfcs_pk_is_collection_slug(): + conn = _fresh_db() + # a second collection under the default project + conn.execute( + "INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) " + "VALUES ('c2','default','document','specs','active','public','Specs')" + ) + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')") + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')") + n = conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE slug='intro'").fetchone()["c"] + assert n == 2 + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','dup','active','default')") + + +def test_cached_rfcs_collection_fk_enforced(): + conn = _fresh_db() + conn.execute("PRAGMA foreign_keys=ON") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('x','X','active','nope')") + + +def test_collaborator_fk_is_composite_on_collection(): + conn = _fresh_db() + conn.execute( + "INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) " + "VALUES ('c2','default','document','specs','active','public','Specs')" + ) + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','c2')") + conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1,'a','A','contributor')") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute( + "INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) " + "VALUES ('intro',1,'contributor','c2')" + ) + with pytest.raises(sqlite3.IntegrityError): + # same slug, a collection with no such entry — composite FK rejects + conn.execute( + "INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) " + "VALUES ('intro',1,'contributor','default')" + ) + + +def test_stars_unique_now_scoped_by_collection(): + conn = _fresh_db() + conn.execute( + "INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) " + "VALUES ('c2','default','document','specs','active','public','Specs')" + ) + 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, collection_id) VALUES ('intro','A','active','default')") + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')") + conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')") + conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','c2')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO stars (user_id, rfc_slug, collection_id) VALUES (1,'intro','default')") + + +def test_memberships_table_replaces_project_members(): + conn = _fresh_db() + cols = {r["name"] for r in conn.execute("PRAGMA table_info(memberships)")} + assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols + # project_members is gone + assert conn.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'" + ).fetchone() is None + conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (9,'x','X','contributor')") + conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'owner')") + # scope_type and role are CHECK-constrained + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'viewer')") diff --git a/backend/tests/test_migration_030_global_scope.py b/backend/tests/test_migration_030_global_scope.py new file mode 100644 index 0000000..ec5db73 --- /dev/null +++ b/backend/tests/test_migration_030_global_scope.py @@ -0,0 +1,91 @@ +"""Migration 030 — the global-scope grant (§22 three-tier S3). + +Proves: `memberships.scope_type` now admits 'global' alongside 'project' and +'collection' (§B.2's four-layer resolver), existing rows survive the rebuild, +and the UNIQUE(scope_type, scope_id, user_id) shape is preserved. +Template: test_migration_029_collections.py. +""" +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 _add_user(conn, uid, login): + conn.execute( + "INSERT INTO users (id, gitea_id, gitea_login, display_name, role) " + "VALUES (?, ?, ?, ?, 'contributor')", + (uid, uid, login, login.capitalize()), + ) + + +def test_global_scope_type_is_accepted(): + conn = _fresh_db() + _add_user(conn, 1, "cleo") + # global grant — the new tier — is accepted. + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('global', '*', 1, 'contributor')" + ) + row = conn.execute( + "SELECT scope_type, scope_id, role FROM memberships WHERE user_id = 1" + ).fetchone() + assert row["scope_type"] == "global" + assert row["scope_id"] == "*" + assert row["role"] == "contributor" + + +def test_project_and_collection_scopes_still_accepted(): + conn = _fresh_db() + _add_user(conn, 1, "ben") + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('project', 'default', 1, 'owner')" + ) + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('collection', 'default', 1, 'contributor')" + ) + n = conn.execute("SELECT COUNT(*) AS n FROM memberships WHERE user_id = 1").fetchone()["n"] + assert n == 2 + + +def test_unknown_scope_type_still_rejected(): + conn = _fresh_db() + _add_user(conn, 1, "x") + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('deployment', '*', 1, 'owner')" + ) + + +def test_one_global_grant_per_user(): + conn = _fresh_db() + _add_user(conn, 1, "cleo") + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('global', '*', 1, 'contributor')" + ) + with pytest.raises(sqlite3.IntegrityError): + conn.execute( + "INSERT INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('global', '*', 1, 'owner')" + ) 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..e6aa679 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") @@ -24,6 +28,13 @@ def test_propose_into_second_project_lands_scoped(app_with_fake_gitea): with TestClient(app) as client: _register_ecomm(fake) provision_user_row(user_id=3, login="alice", role="contributor") + # §22 S3: the grandfathered implicit-public baseline covers only the N=1 + # `default` collection; a second project requires an explicit scope grant + # to write. Grant alice contributor at the ecomm project. + from app import db + db.conn().execute( + "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES ('project', 'ecomm', 3, 'contributor')") sign_in_as(client, user_id=3, gitea_login="alice", display_name="Alice", role="contributor", email="alice@test") r = client.post("/api/projects/ecomm/rfcs/propose", json={ @@ -52,8 +63,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_propose_vertical.py b/backend/tests/test_propose_vertical.py index 6045619..f4bc22c 100644 --- a/backend/tests/test_propose_vertical.py +++ b/backend/tests/test_propose_vertical.py @@ -90,6 +90,28 @@ class FakeGitea: self._commit_counter += 1 return f"sha{self._commit_counter:04d}" + def _dir_listing(self, owner, repo, ref, dirpath): + """Children directly under `dirpath` on (owner, repo, ref): files as + `type: file` and immediate subdirectories as `type: dir` (the shape real + Gitea returns for a contents listing).""" + prefix = (dirpath.rstrip("/") + "/") if dirpath else "" + files: dict[str, dict] = {} + dirs: set[str] = set() + for (o, r, br, p), data in self.files.items(): + if (o, r, br) != (owner, repo, ref) or not p.startswith(prefix): + continue + rest = p[len(prefix):] + if "/" in rest: + dirs.add(rest.split("/", 1)[0]) + elif rest: + files[p] = data + children = [{"name": n, "path": prefix + n, "type": "dir"} for n in sorted(dirs)] + children += [ + {"name": p.rsplit("/", 1)[-1], "path": p, "type": "file", "sha": d["sha"]} + for p, d in sorted(files.items()) + ] + return children + def _enrich_pr(self, owner: str, repo: str, pr: dict) -> dict: """Return the PR with mergeability fields filled in. @@ -227,6 +249,16 @@ class FakeGitea: } return httpx.Response(201, json={"name": new}) + # GET /repos/{owner}/{repo}/contents (root listing, empty path). §22 S2: + # the registry mirror walks the content-repo root for collection + # subfolders, so the simulator models a root directory listing that + # surfaces both file and `dir` children. + m_root = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/?", path) + if method == "GET" and m_root: + owner, repo = m_root.groups() + ref = request.url.params.get("ref", "main") + return httpx.Response(200, json=self._dir_listing(owner, repo, ref, "")) + # GET /repos/{owner}/{repo}/contents/{path}?ref=... m = re.fullmatch(r"/repos/([^/]+)/([^/]+)/contents/(.+)", path) if method == "GET" and m: @@ -242,17 +274,8 @@ class FakeGitea: "sha": f["sha"], "content": base64.b64encode(f["content"].encode()).decode(), }) - # Directory listing - prefix = fpath.rstrip("/") + "/" - children = [] - for (o, r, br, p), data in self.files.items(): - if (o, r, br) == (owner, repo, ref) and p.startswith(prefix) and "/" not in p[len(prefix):]: - children.append({ - "name": p.rsplit("/", 1)[-1], - "path": p, - "type": "file", - "sha": data["sha"], - }) + # Directory listing — both file and subdir children. + children = self._dir_listing(owner, repo, ref, fpath) if children: return httpx.Response(200, json=children) return httpx.Response(404, json={"message": "not found"}) 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/<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"] diff --git a/backend/tests/test_s3_scope_roles_vertical.py b/backend/tests/test_s3_scope_roles_vertical.py new file mode 100644 index 0000000..2b6f018 --- /dev/null +++ b/backend/tests/test_s3_scope_roles_vertical.py @@ -0,0 +1,287 @@ +"""Slice S3 — scope-role enforcement + collection-grain visibility (@S3). + +The acceptance gate for S3 is "every Part C.1 scenario passes" (the design doc +docs/design/2026-06-05-three-tier-projects-collections.md, §C.1, tagged @S3) plus +the operator's S3 visibility requirements (a collection settable public/hidden; +hidden = visible to project/global scope contributors but not the public; a +collection's visibility may be set only as strict or stricter than its project). + +The §B.2 resolver folds four layers — global → project → collection → per-entry — +most-permissively, with no negative override. The scenarios below are exercised +directly against the resolver/gate helpers, and the visibility ones additionally +through the HTTP surface. + +Background (C.1): a deployment with a project "ohm" owning collections "model" +(document) and "features" (bdd); a second project "acme" with collection +"specs". Plus a hidden ("gated") collection "secret" under ohm for the +hidden-from-public scenarios. +""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from test_propose_vertical import ( # noqa: F401 — fixtures land via import + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _su(user_id: int, login: str, role: str = "contributor", *, state: str = "granted"): + from app import auth + + return auth.SessionUser( + user_id=user_id, gitea_id=user_id, gitea_login=login, + display_name=login.capitalize(), email=f"{login}@test", avatar_url="", + role=role, permission_state=state, + ) + + +def _project(pid: str, visibility: str = "public", content_repo: str = "meta") -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO projects (id, name, content_repo, visibility, updated_at) " + "VALUES (?, ?, ?, ?, datetime('now'))", + (pid, pid.capitalize(), content_repo, visibility), + ) + + +def _collection(cid: str, project_id: str, *, ctype: str = "document", + visibility: str = "public", subfolder: str | None = None) -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO collections " + "(id, project_id, type, subfolder, initial_state, visibility, name, created_at, updated_at) " + "VALUES (?, ?, ?, ?, 'super-draft', ?, ?, datetime('now'), datetime('now'))", + (cid, project_id, ctype, subfolder if subfolder is not None else cid, + visibility, cid.capitalize()), + ) + + +def _grant(scope_type: str, scope_id: str, user_id: int, role: str) -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) " + "VALUES (?, ?, ?, ?)", + (scope_type, scope_id, user_id, role), + ) + + +def _seed_world() -> None: + """The C.1 background plus a hidden collection and a second project.""" + _project("ohm", "public") + _collection("ohm", "ohm", subfolder="") # ohm's structural default + _collection("model", "ohm", ctype="document") + _collection("features", "ohm", ctype="bdd") + _collection("secret", "ohm", visibility="gated") # hidden from public + _project("acme", "public") + _collection("specs", "acme", ctype="specification") + # the cast + for uid, login in [(1, "ada"), (2, "ben"), (3, "cleo"), (4, "dan"), + (5, "eve"), (6, "fay"), (7, "gil"), (8, "hana")]: + provision_user_row(user_id=uid, login=login, role="contributor") + _grant("collection", "model", 1, "contributor") # ada + _grant("project", "ohm", 2, "contributor") # ben + _grant("global", "*", 3, "contributor") # cleo + _grant("collection", "features", 4, "owner") # dan + _grant("project", "ohm", 5, "owner") # eve + _grant("collection", "model", 6, "contributor") # fay (+ project owner below) + _grant("project", "ohm", 6, "owner") # fay + _grant("project", "ohm", 7, "contributor") # gil + # hana (8): no grant. + + +# --------------------------------------------------------------------------- +# C.1 — role usage: inheritance and the most-permissive union +# --------------------------------------------------------------------------- + + +def test_c1_1_collection_contributor_proposes_only_in_that_collection(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + ada = _su(1, "ada") + # may submit a new entry in ohm/model + assert auth.can_contribute_in_collection(ada, "model") is True + # ohm/features is read-only and propose is not offered + assert auth.can_read_collection(ada, "features") is True + assert auth.can_contribute_in_collection(ada, "features") is False + + +def test_c1_2_project_contributor_proposes_in_every_collection(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + ben = _su(2, "ben") + assert auth.can_contribute_in_collection(ben, "model") is True + assert auth.can_contribute_in_collection(ben, "features") is True + # a collection added later is writable with no new grant + _collection("roadmap", "ohm", ctype="document") + assert auth.can_contribute_in_collection(ben, "roadmap") is True + + +def test_c1_3_global_contributor_proposes_everywhere(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + cleo = _su(3, "cleo") + assert auth.can_contribute_in_collection(cleo, "model") is True + assert auth.can_contribute_in_collection(cleo, "specs") is True # acme + + +def test_c1_4_collection_owner_administers_one_collection_only(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + dan = _su(4, "dan") + # graduate / mark-reviewed / manage membership in ohm/features + assert auth.is_collection_superuser(dan, "features") is True + # but not change ohm project settings + assert auth.is_project_superuser(dan, "ohm") is False + assert auth.can_create_collection(dan, "ohm") is False + # and not act on entries in ohm/model + assert auth.is_collection_superuser(dan, "model") is False + assert auth.can_contribute_in_collection(dan, "model") is False + + +def test_c1_5_project_owner_administers_all_collections_and_creates_more(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + eve = _su(5, "eve") + assert auth.is_collection_superuser(eve, "model") is True + assert auth.is_collection_superuser(eve, "features") is True + assert auth.is_project_superuser(eve, "ohm") is True # edit project settings + assert auth.can_create_collection(eve, "ohm") is True # create a new collection + + +def test_c1_6_most_permissive_union_higher_grant_wins(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + fay = _su(6, "fay") + # collection RFC Contributor at model + project Owner at ohm → acts as Owner in model + assert auth.effective_scope_role(fay, "model") == "owner" + assert auth.is_collection_superuser(fay, "model") is True + + +def test_c1_7_no_negative_override(app_with_fake_gitea): + from app import auth, db + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + gil = _su(7, "gil") + # gil can propose in ohm/model via the project grant… + assert auth.can_contribute_in_collection(gil, "model") is True + # …and there is no collection-scope row to remove at model while keeping + # the project grant (a child cannot subtract a parent grant). + row = db.conn().execute( + "SELECT 1 FROM memberships WHERE user_id = 7 AND scope_type = 'collection' AND scope_id = 'model'" + ).fetchone() + assert row is None + + +def test_c1_8_granted_account_no_role_sees_only_public(app_with_fake_gitea): + from app import auth + app, _ = app_with_fake_gitea + with TestClient(app): + _seed_world() + hana = _su(8, "hana") + # may read public collections + assert auth.can_read_collection(hana, "model") is True + # but is not offered the propose action anywhere (no scope role; the + # grandfathered baseline covers only the N=1 `default` collection) + assert auth.can_contribute_in_collection(hana, "model") is False + assert auth.can_contribute_in_collection(hana, "features") is False + assert auth.can_contribute_in_collection(hana, "specs") is False + # gated (hidden) collections do not appear for her + assert auth.can_read_collection(hana, "secret") is False + + +# --------------------------------------------------------------------------- +# Collection-grain visibility — the operator's S3 requirements +# --------------------------------------------------------------------------- + + +def test_hidden_collection_invisible_to_public_visible_to_scope_holder(app_with_fake_gitea): + """A gated collection is omitted from the directory and 404s on read for the + public, yet is listed + readable for a scope-role contributor.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + # anonymous: the gated 'secret' collection is not listed, and 404s. + listed = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} + assert "secret" not in listed + assert "model" in listed # public ones still listed + assert client.get("/api/projects/ohm/collections/secret").status_code == 404 + assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 404 + + # ben (project contributor) sees and reads it. + sign_in_as(client, user_id=2, gitea_login="ben", display_name="Ben", role="contributor") + listed2 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} + assert "secret" in listed2 + assert client.get("/api/projects/ohm/collections/secret").status_code == 200 + assert client.get("/api/projects/ohm/collections/secret/rfcs").status_code == 200 + + # hana (granted, no role) is back to the public view. + sign_in_as(client, user_id=8, gitea_login="hana", display_name="Hana", role="contributor") + listed3 = {c["id"] for c in client.get("/api/projects/ohm/collections").json()["items"]} + assert "secret" not in listed3 + assert client.get("/api/projects/ohm/collections/secret").status_code == 404 + + +def test_collection_visibility_strictness_validated_at_create(app_with_fake_gitea): + """A collection may be created only as strict or stricter than its project; + a looser request is refused (422). On a gated project, a 'public' collection + is rejected.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + _project("locked", "gated") + _collection("locked", "locked", subfolder="", visibility="gated") + # eve is a deployment owner here to clear the create-authority gate; + # the strictness check fires regardless. + provision_user_row(user_id=9, login="root", role="owner") + sign_in_as(client, user_id=9, gitea_login="root", display_name="Root", role="owner") + r = client.post("/api/projects/locked/collections", json={ + "collection_id": "wideopen", "type": "document", "visibility": "public", + }) + assert r.status_code == 422, r.text + assert "looser" in r.json()["detail"] + + +def test_create_collection_allowed_for_project_owner_not_plain_contributor(app_with_fake_gitea): + """§B.1: a project-scope Owner may create a collection; a plain granted + contributor with no project/global grant may not (403).""" + app, fake = app_with_fake_gitea + with TestClient(app) as client: + _seed_world() + # gil is only a project *contributor* on ohm — per §B.1 a project-scope + # contributor CAN create collections (the project-level create + # affordance). A collection-scope grant cannot. + sign_in_as(client, user_id=7, gitea_login="gil", display_name="Gil", role="contributor") + r_ok = client.post("/api/projects/ohm/collections", json={ + "collection_id": "fromgil", "type": "document", "visibility": "public", + }) + assert r_ok.status_code in (200, 502), r_ok.text # past the authz gate + + # ada holds only a *collection*-scope grant (at model) — no create right. + sign_in_as(client, user_id=1, gitea_login="ada", display_name="Ada", role="contributor") + r_no = client.post("/api/projects/ohm/collections", json={ + "collection_id": "fromada", "type": "document", "visibility": "public", + }) + assert r_no.status_code == 403, r_no.text diff --git a/docs/design/2026-06-05-three-tier-projects-collections.md b/docs/design/2026-06-05-three-tier-projects-collections.md new file mode 100644 index 0000000..4592e3f --- /dev/null +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -0,0 +1,652 @@ +# Draft spec — §22 refactor: three tiers (deployment → project → RFC collection) + +> Status: **draft for review.** Binding voice, but not yet merged into +> `SPEC.md`. This doc **revises the §22 model** in +> [`multi-project-spec.md`](./multi-project-spec.md) from two tiers +> (deployment → project, where a "project" *is* a corpus) to **three tiers** +> (deployment → project → RFC collection, where the *collection* is the +> corpus). It supersedes the conflicting parts of that draft; the parts it does +> not touch (the registry-is-git-truth stance, the cache mirror, visibility +> semantics, the `type`/`initial_state`/`unreviewed` machinery) carry over +> unchanged, re-homed onto the collection. Rationale and the decisions behind +> this live in [`multi-project.md`](./multi-project.md) and session 0072. +> +> ⚠️ **CORRECTION (session 0072, after code re-check).** Parts of §0/§A.3/§E +> were drafted on a stale-memory premise that "Plan B (migration 028) and +> M3-frontend have not shipped." **That is false.** As of v0.39.0 the entire +> **two-tier** model is shipped to `main`: migration 028 already rebuilt the +> slug PK to `(project_id, slug)`; v0.35.0 shipped `/p/<project>/` routing and +> the live `/p/<project>/e/<slug>` URLs; v0.37.0/0.38.0 shipped per-project +> read + propose. Inserting the third tier is therefore an **evolution of a +> shipped system**, not a revision of unshipped designs. The migration strategy +> (Part E) was **re-decided on these corrected facts** (session 0072): a new +> **migration 029** adds a *collection* grain *beneath* today's project, plus a +> breaking `/p/<project>/e/<slug>` → `/p/<project>/c/<collection>/e/<slug>` URL +> change with 308s. The structural model (Parts A–D) is unaffected. Target +> release: a further pre-1.0 minor with breaking changes + upgrade steps (§20.2). + +--- + +## 0. Why this revision + +The original §22 (`multi-project-spec.md`) gave a deployment **N projects**, +where each project *was* a single typed corpus: one content repo, one `type`, +one slug namespace, one member roster. That conflates two responsibilities — +**organizational grouping** and **a typed body of entries** — into one noun. + +This revision splits them. A **project** becomes a pure grouping tier (settings ++ one content repo) that holds **any number of RFC collections**; an **RFC +collection** is the typed corpus the original §22 called a "project." Everything +the original §22 said about a corpus (type, slug namespace, catalog, philosophy, +landing state, review flag, membership) moves down one level to the collection; +the deployment level is unchanged. + +⚠️ The two-tier model is **already shipped** (v0.39.0): migration 028 rebuilt +the slug PK to `(project_id, slug)`, and `/p/<project>/e/<slug>` URLs are live +(v0.35.0). So inserting the third tier evolves a shipped system — see Part E +for the decided strategy (a new migration 029 adding a collection grain beneath +today's project, + a breaking URL change with 308s). + +--- + +# Part A — The three-tier model + +## A.1 The tiers + +``` +deployment (= "global" in the UI) one Gitea org, one bot, one account + │ system, one inbox, one running process; + │ the surface a visitor first lands on. + └─ project ◀ NEW a named grouping + project settings; + │ owns exactly ONE content repo. No type. + └─ RFC collection a typed corpus: type, slug namespace, + │ catalog, philosophy, initial_state, + │ unreviewed flag, members. (= what the + │ original §22 called a "project".) + └─ entry an RFC / spec / feature, identified by + its slug within the collection. +``` + +- **Deployment / "global."** Unchanged top tier. Owns accounts, the §6 + admission gate, the §15 inbox, the §1 bot, and the deployment landing + directory. Its management surface is **projects + global settings**. +- **Project** *(new)*. Belongs to exactly one deployment; never moves. Owns one + content repo (§A.2) and carries project settings (name, tagline, theme, + visibility, model universe). Has **no `type`** of its own. Its management + surface is **RFC collections + project settings**. +- **RFC collection.** A typed subfolder of its project's content repo (§A.2). + Carries everything the original §22 pinned on a "project": the immutable + `type` (§22.4a `document` | `specification` | `bdd` | …), the per-collection + slug namespace (§A.3), `initial_state` (§22.4b), the `unreviewed` flag + (§22.4c), catalog, philosophy. This is "closest to what OHM originally + managed as a single corpus." +- **Entry.** Unchanged (§2). Identified by its slug **within its collection**. + +A collection belongs to exactly one project; a project to exactly one +deployment. Isolation (§22.1) now holds at the **collection** grain: an RFC, +branch, thread, star, or watch belongs to exactly one collection. + +## A.2 Storage and git-truth + +Two git sources, both read by the bot, both mirrored into cache tables the §4 +way: + +1. **The registry repo** (`projects.yaml`, located by `REGISTRY_REPO`, §22.2) + declares **projects** — `id`, `name`, `content_repo`, settings, `visibility`, + `theme`, `enabled_models`. `content_repo` moves **up** from the collection + (original §22) to the project: a project owns exactly one content repo. + +2. **Each project's content repo** declares its **collections** as typed + subfolders, each carrying a **`.collection.yaml` manifest** (the collection's + `type`, `visibility`, `initial_state`). The registry mirror walks the content + repo and reads these manifests, so collection configuration is git-truth and + survives a cache rebuild — exactly as entry frontmatter does. + +```yaml +# projects.yaml (registry repo root) +deployment: + name: Wiggleverse + tagline: ... +projects: + - id: ohm + name: Open Human Model + content_repo: ohm-content # ONE repo; collections live inside it + visibility: public # gated | public | unlisted (§22.5) + theme: { accent: "#5b5bd6" } + enabled_models: [claude, gemini] +``` + +```yaml +# ohm-content/features/.collection.yaml (one per collection subfolder) +type: bdd # document | specification | bdd — immutable +visibility: gated # defaults to the project's, may narrow +initial_state: active # defaults from type (§22.4b) +name: Feature scenarios +``` + +``` +ohm-content/ + model/ + .collection.yaml # type: document + intro.md + specs/ + .collection.yaml # type: specification + runtime.md + features/ + .collection.yaml # type: bdd + login.md +``` + +**Creation is in-app, wrapping a bot commit, at both tiers:** + +- **+ New project** (a global Owner action): the bot **creates a Gitea content + repo** under the deployment org, **commits a project entry** to + `projects.yaml`, and the mirror picks it up. +- **+ New collection** (a project Owner / RFC Contributor-with-create action): + the bot **commits a new subfolder + `.collection.yaml`** to the project's + content repo; the mirror picks it up. + +The in-app button is a thin convenience over a git write; nothing becomes app +state that git cannot rebuild. `projects` and `collections` cache rows are never +written from user actions directly — they flow from the mirror only (§22.2). +**Membership** (§B-roles) remains app state, as `rfc_collaborators` always has +been — it churns at user speed and is not document state. + +## A.3 Identity and routing + +The slug is unique **within a collection**; the fully-qualified identity is +`(project, collection, slug)`. `model/intro` and `specs/intro` coexist. No type +prefix, no numbers (the §22.4 retirement of `RFC-NNNN` allocation stands; +legacy `id` frontmatter remains a frozen, non-identity display label). + +Canonical route: + +``` +/p/<project>/c/<collection>/e/<slug> +``` + +The `c/` segment keeps collection ids from colliding with reserved +project-level segments (project settings, the collection directory). Reserved +**collection-level** siblings (`proposals`, `philosophy`) sit under +`/p/<project>/c/<collection>/…`. The displayed entry noun ("RFC", "Spec", +"Feature") is the collection type's label (§22.4a), not part of the path. + +The root `/` is the deployment landing: a **directory of projects** the visitor +can see (§22.5). `/p/<project>/` is the project landing: a **directory of +collections** in that project the visitor can see. Conveniences: + +- `/p/<project>/` redirects to its sole collection when the project has exactly + one visible collection. +- `/` redirects to the sole visible project when there is exactly one (the N=1 + case, §A.6). + +⚠️ **Backcompat is heavier than first drafted.** `/p/<project>/e/<slug>` URLs +**are live** (v0.35.0), so adding the `/c/<collection>/` segment is a breaking +URL change: the shipped `/p/<project>/e/<slug>` must **308-redirect** to +`/p/<project>/c/<default-collection>/e/<slug>`, alongside the pre-multi-project +`/rfc/<slug>` → `/p/<default-project>/c/<default-collection>/…` redirect. Both +are handled in the migration (§A.6 / Part E). + +--- + +# Part B — Roles and authorization + +## B.1 One role vocabulary, attached at a scope + +There is **one role enum — `{owner, contributor}`** — displayed as **Owner** +and **RFC Contributor**. A grant *attaches that role at a scope*: **global**, +**project**, or **collection**. "Owner at all levels, RFC Contributor at all +levels" is therefore literal — the same two words at every tier, not a fresh +pair invented per tier. + +| Role | Capabilities within its scope's subtree | +|---|---| +| **Owner** | Superuser: manage settings and membership; create child projects/collections; act on any entry (merge on behalf, graduate, mark-reviewed, withdraw/reopen, set branch visibility). | +| **RFC Contributor** | Propose entries, create branches, open PRs, claim unclaimed super-drafts, participate in discussion. At **project** (or global) scope this additionally includes **creating collections** in that project — the "anyone at the project level with permission to create a collection" affordance. (A *collection*-scope grant cannot create sibling collections; creating one is a project-level action.) | + +This **reconciles** the role names the prior drafts accumulated — they were +different words for the same idea: + +| Prior spec term | Tier it lived at | Unified role | +|---|---|---| +| deployment `owner` / `admin` (§6.1) | global | **Owner** (global) | +| deployment `contributor` (§6.1) | global | **RFC Contributor** (global) | +| `project_admin` (M2 §22.6) | the corpus → now the **collection** | **Owner** (collection) | +| `project_contributor` (M2 §22.6) | the corpus → now the **collection** | **RFC Contributor** (collection) | +| `project_viewer` (M2 §22.6) | the corpus | *deferred* (read-only grant; not one of this pass's two) | + +> **Scope-narrowing, not renaming.** Collapsing `owner`/`admin` into one +> **Owner** and dropping `viewer` for this pass are deliberate deferrals (the +> launch ask: "we don't need to get all permissions right yet"). When they +> return they **re-split out of** Owner / add a tier; they are not aliases of +> the unified roles. The richer set is future work. + +## B.2 Inheritance and resolution + +Grants inherit **downward**, are **additive**, and admit **no negative +override**: + +- A grant at **global** covers every project and collection in the deployment. +- A grant at **project** covers every collection in that project. +- A grant at **collection** covers just that collection. +- You **cannot** grant a role at a parent scope and revoke it at a child (the + launch ask: "too complex"). Resolution never subtracts a parent grant. + +Effective authority on an entry generalizes the §22.7 most-permissive union +from three layers to four (global → project → collection → per-entry): + +``` +effective authority on an entry = + global role (users.role) + ∪ project role (membership at the entry's project) + ∪ collection role (membership at the entry's collection) + ∪ per-entry authority (owners / arbiters / rfc_collaborators — §6.3, §12) + then minus §6.2 write-mute and §22.5 visibility (subtractive, as today) +``` + +**Per-entry authority is a distinct, finer layer — not a synonym.** `owners` / +`arbiters` / `rfc_collaborators` apply to *one specific entry* (§6.3, §12); the +three named scopes apply to a *subtree*. Per-entry authority is unchanged and +sits beneath collection in the union. `arbiter` is narrower than Owner (one +entry, not a subtree) and stays distinct. + +## B.3 Schema impact + +- `users.role` continues to carry the **global** role (deployment owner / + contributor). +- M2's `project_members(project_id, role)` rows were attached at what we now + call the **collection**. They generalize into a single polymorphic + **`memberships(scope_type ∈ {project, collection}, scope_id, user_id, role, + granted_by, granted_at)`** table; the M2 rows migrate to + `scope_type='collection'`. The **project** tier gets the same two roles, + freshly grantable. +- The M2 three-role enum (`viewer`/`contributor`/`admin`) collapses to + `{owner, contributor}`: `project_admin → owner`, `project_contributor → + contributor`, `project_viewer →` a read grant (no write) folded into + visibility, not a membership role this pass. + +--- + +# Part C — Behavioral scenarios (BDD) + +> These Gherkin scenarios are the behavioral spec for **role usage**, +> **invitation**, and **empty-state** experiences. They attach to the rewritten +> §22 as **§22.6a (role & invitation scenarios)**. They are written so they can +> *also* seed a `bdd`-type collection later (the framework dogfooding its own +> model). "Owner"/"RFC Contributor" are the unified roles (§B.1); a *scope* in +> the `Given` is global / project / collection. +> +> **Each scenario carries a `@S<n>` tag** naming the **slice** (Part E) that +> makes it pass — the "which scenarios are done after this slice" marker. After +> shipping slice S<n>, its acceptance gate is "every `@S<n>` scenario passes" +> (e.g. `--tags @S3`). The Part E table is the inverse index (slice → +> scenarios). + +## C.1 Role usage — inheritance and the most-permissive union + +```gherkin +Feature: Scope roles grant authority over a subtree + As a member of the deployment + I want a role granted at one tier to apply to everything beneath it + So that I can be invited once and work across the right set of collections + + Background: + Given a deployment with a project "ohm" + And "ohm" owns collections "model" (document) and "features" (bdd) + + @S3 + Scenario: Collection RFC Contributor may propose only in that collection + Given "ada" is RFC Contributor at collection "ohm/model" + When "ada" opens the propose form in "ohm/model" + Then she may submit a new entry + When "ada" opens "ohm/features" + Then she sees it read-only and the propose action is not offered + + @S3 + Scenario: Project RFC Contributor may propose in every collection of the project + Given "ben" is RFC Contributor at project "ohm" + Then "ben" may propose in "ohm/model" + And "ben" may propose in "ohm/features" + And a collection added to "ohm" later is writable by "ben" with no new grant + + @S3 + Scenario: Global RFC Contributor may propose in every collection of every project + Given a second project "acme" with collection "acme/specs" + And "cleo" is RFC Contributor at global scope + Then "cleo" may propose in "ohm/model" and "acme/specs" + + @S3 + Scenario: Collection Owner administers one collection only + Given "dan" is Owner at collection "ohm/features" + Then "dan" may graduate, mark-reviewed, and manage membership in "ohm/features" + But "dan" may not change "ohm" project settings + And "dan" may not act on entries in "ohm/model" + + @S3 + Scenario: Project Owner administers all collections and may create more + Given "eve" is Owner at project "ohm" + Then "eve" may manage membership in "ohm/model" and "ohm/features" + And "eve" may edit "ohm" project settings + And "eve" may create a new collection in "ohm" + + @S3 + Scenario: Most-permissive union — the higher grant wins + Given "fay" is RFC Contributor at collection "ohm/model" + And "fay" is Owner at project "ohm" + Then "fay" acts as Owner in "ohm/model" + + @S3 + Scenario: No negative override — a child cannot subtract a parent grant + Given "gil" is RFC Contributor at project "ohm" + Then there is no control to remove "gil" from "ohm/model" while keeping the project grant + And "gil" can propose in "ohm/model" + + @S3 + Scenario: A granted account with no scope role sees only public content + Given "hana" has a granted deployment account but no global, project, or collection role + Then "hana" may read public collections under the §6.1 anonymous-read contract + But "hana" is not offered the propose action anywhere + And gated projects and collections do not appear for her +``` + +## C.2 Invitation — who may invite whom, at which scope + +```gherkin +Feature: Inviting users to a scope role + As an Owner of a scope + I want to grant Owner or RFC Contributor at my scope or any scope beneath it + So that collaborators get exactly the reach they need + + @S4 + Scenario: Project Owner invites at project scope (covers all collections) + Given "eve" is Owner at project "ohm" + When "eve" invites "ivy" as RFC Contributor at project "ohm" + Then a membership row is written at scope project "ohm" + And "ivy" receives a §15 notification naming the project and role + And "ivy" may propose in every collection of "ohm" + + @S4 + Scenario: Owner invites at a specific collection + When "eve" invites "jo" as RFC Contributor at collection "ohm/features" + Then a membership row is written at scope collection "ohm/features" + And "jo" may propose in "ohm/features" but not "ohm/model" + + @S4 + Scenario: Invitation reach is bounded by the inviter's scope + Given "dan" is Owner at collection "ohm/features" + Then "dan" may invite users to roles in "ohm/features" + But "dan" is not offered the control to invite at project "ohm" or global scope + + @S4 + Scenario: RFC Contributors do not manage membership + Given "ben" is RFC Contributor at project "ohm" + Then "ben" may propose and create collections in "ohm" + But "ben" is not offered any invite control (membership is an Owner capability) + + @S4 + Scenario: The invite UI offers no grant-at-parent-revoke-at-child option + Given "eve" is Owner at project "ohm" + When "eve" opens the invite control for "ivy" at project "ohm" + Then she may choose role Owner or RFC Contributor and scope project or a single collection + But there is no option to grant at "ohm" and exclude a child collection + + @S4 + Scenario: Re-inviting at a broader scope supersedes the narrower grant + Given "jo" is RFC Contributor at collection "ohm/features" + When "eve" invites "jo" as RFC Contributor at project "ohm" + Then "jo" has the role across all of "ohm" + And the redundant collection-scope row is removed or shown as subsumed + + @S4 + Scenario: A pending deployment account cannot be granted write + Given "kim" has permission_state "pending" at the deployment + When "eve" invites "kim" as RFC Contributor at project "ohm" + Then the grant is recorded but confers no write capability until "kim" is granted at the deployment (§6) +``` + +## C.3 Empty-state experiences + +```gherkin +Feature: Empty states at each tier + As a viewer of a tier with nothing in it yet + I want a clear, role-appropriate empty state + So that I know whether there is an action to take or simply nothing to see + + @S5 + Scenario: Global directory with no projects — Owner + Given a deployment with no projects + And "root" is Owner at global scope + When "root" lands on "/" + Then she sees an empty directory with a "Create your first project" call to action + + @S5 + Scenario: Global directory with no visible projects — non-owner + Given a deployment whose only projects are gated + And "vee" is a granted account with no roles + When "vee" lands on "/" + Then she sees an empty directory with no create action + And a note that there is nothing shared with her yet + + @S4 + Scenario: Project with no collections — project Owner + Given project "ohm" with no collections + And "eve" is Owner at project "ohm" + When "eve" lands on "/p/ohm/" + Then she sees an empty collection directory with a "Create your first collection" call to action + And the action lets her choose a type and subfolder + + @S4 + Scenario: Project with no collections — RFC Contributor without create rights + Given project "ohm" with no collections + And "ben" is RFC Contributor at collection scope elsewhere only + When "ben" lands on "/p/ohm/" + Then he sees an empty collection directory with no create action + + @S4 + Scenario: Collection with no entries — a contributor + Given collection "ohm/model" with no entries + And "ada" is RFC Contributor at collection "ohm/model" + When "ada" lands on "/p/ohm/c/model/" + Then she sees an empty catalog with a "Propose the first entry" call to action + + @S2 + Scenario: Collection with no entries — an anonymous reader + Given a public collection "ohm/model" with no entries + When an anonymous visitor lands on "/p/ohm/c/model/" + Then they see an empty catalog with no propose action and a sign-in prompt + + @S1 + Scenario: Single-collection project skips the directory + Given project "ohm" with exactly one visible collection "model" + When a visitor lands on "/p/ohm/" + Then they are redirected to "/p/ohm/c/model/" + + @S1 + Scenario: Single-project deployment skips the directory + Given a deployment with exactly one visible project "ohm" + When a visitor lands on "/" + Then they are redirected to "/p/ohm/" +``` + +--- + +# Part D — Amendments to the original §22 draft + +Applied in place when §22 is rewritten; listed here as the change surface. + +- **§22 preamble / §22.1.** "A deployment hosts N projects, each a corpus" → + "a deployment hosts N **projects**, each owning one content repo and holding + N **RFC collections**, each collection a typed corpus." Isolation moves to the + collection grain. +- **§22.2 Registry.** `projects.yaml` declares projects with one `content_repo` + each (no per-collection `content_repo`). New: collections are declared by + `.collection.yaml` manifests inside the content repo; the mirror reads them. + In-app create-project / create-collection actions wrap bot commits. +- **§22.3 Content repos.** "One per project" (not per collection); collections + are subfolders within it. +- **§22.4 / §22.4a-c.** Slug is unique **per collection**. `type`, + `initial_state`, and `unreviewed` are **collection** properties (re-homed from + "project"). Unchanged otherwise. +- **§22.5 Visibility.** Applies at **both** project and collection. A collection + defaults to its project's visibility and may narrow it; reading/writing a + collection requires passing both gates. +- **§22.6 Membership and roles → the unified model (Part B).** Replace the three + `project_*` roles with `{owner, contributor}` at `{global, project, + collection}` via a polymorphic `memberships` table. Add **§22.6a** = the + Part C scenarios. +- **§22.7 Composition.** Four-layer most-permissive union (global → project → + collection → per-entry); no negative override. +- **§22.9 / §22.10 Branding & routing.** Routes gain the collection segment: + `/p/<project>/c/<collection>/…`. `GET /api/deployment` lists visible projects; + add `GET /api/projects/:id` (lists visible collections + project settings) and + `GET /api/projects/:id/collections/:cid` (collection settings incl. `type`). +- **§22.11 Notifications / §22.13 migration / §5 amendments.** `project_id` + becomes `collection_id` on every entry-scoped row (the corpus grain is now the + collection); a separate `project_id` exists only on the `collections` table + and project-scoped rows. The §22.13 default project gains a default collection + (§A.6 below). + +--- + +# Part E — Revised slicing plan (the roadmap re-slot) + +**Strategy (session 0072, decided on corrected facts).** The two-tier model is +shipped end-to-end (v0.39.0): migration 028 keyed entries `(project_id, slug)`; +v0.35.0 shipped `/p/<project>/` routing + live `/p/<project>/e/<slug>` URLs; +v0.37.0/0.38.0 shipped per-project read + propose. Inserting the third tier is +therefore an **evolution of a shipped system**. The chosen mapping **adds a +collection grain *beneath* today's project** — the shipped `projects` table +stays the grouping tier (it already owns `content_repo`, where §A.2 wants it), +a new `collections` table holds the per-corpus fields, and entries re-key to the +finer `(collection_id, slug)`. + +**Slicing principle (session 0072): every slice ends in a *usable* deployment, +and declares the Part C scenarios it makes pass** (its `@S<n>` tag). "Usable" +means the deployment runs and either gains a capability or provably loses none +(N=1 unchanged). A slice is done when its `@S<n>` scenarios are green. + +- **Landed, unchanged (v0.39.0):** M1–M2, M3-backend Plan A **and** Plan B + (read+propose, mig 028), M3-frontend (`/p/<project>/` routing), §22.13 + re-stamp. None of this is rebuilt; it is *evolved* by the slices below. + +- **S1 — The collection grain exists (invisible default).** Migration 029 + + backend threading + the default-routing redirect, shipped **together** (they + are coupled — renaming `project_id`→`collection_id` breaks every reader until + the code is threaded, so a green tree needs both). Migration 029 + (`029_collections.sql`): (1) add a `collections` table + `(id, project_id, type, subfolder, initial_state, visibility, name, + registry_sha)`; (2) move the per-corpus fields (`type`, `initial_state`, + visibility) **down** from `projects` (leaving it `(id, content_repo, + visibility, name, tagline, theme, enabled_models, …)`); (3) create one default + collection per project (id `default`, `subfolder` = repo root); (4) re-key + every entry-scoped table `(project_id, slug)` → `(collection_id, slug)` via the + `028_project_scoped_keys.sql` rebuild pattern (`__new`, copy, drop, rename, + FK-off + `foreign_key_check`); (5) generalize `project_members` → + `memberships(scope_type ∈ {project, collection}, …)`, collapsing the role enum + (§B.3). Then thread `collection_id` through `app/auth.py` / `app/projects.py` + / `app/cache.py` / the `api_*` writers, and **308** `/p/<project>/e/<slug>` → + `/p/<project>/c/<default>/e/<slug>`. **Usable end-state:** the deployment runs + exactly as before, now with a real collection layer and one extra path segment. + **Completes:** `@S1` (the single-collection / single-project redirect skips). + +- **S2 — Create & navigate a second collection.** *(Shipped v0.41.0.)* Teach the + registry mirror to + read `.collection.yaml`; add the bot-commit-wrapped **create-collection** + endpoint (authorized by existing deployment owner/admin for now — the scoped + role surface lands in S3); the project collection-directory at `/p/<project>/`; + collection-scoped propose/serve under `/p/<project>/c/<collection>/`. + **Usable end-state:** an admin creates a `bdd` collection beside the document + one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of + an empty collection catalog). + +- **S3 — Scope-role enforcement.** *(Shipped v0.42.0.)* The four-layer + most-permissive resolver (§B.2) over `{owner, contributor}` grants at + `{global, project, collection}` (migration 030 adds the `global` scope_type), + with grants applied administratively (DB / the Owner-authorized create + surface); every write gate re-checked under the collection axis. **Plus the + operator's S3 visibility requirements:** collection-grain visibility is + enforced — a `gated` collection is hidden from the public (404, omitted from + the directory) yet visible to scope-role contributors; a collection's + visibility may be set only as strict or stricter than its project's + (`public` < `unlisted` < `gated`). **Keystone reconciliation (session 0076):** + §B.1/§B.3's literal "deployment contributor = global RFC Contributor" + contradicted the C.1 "hana" scenario and the M2 implicit-public baseline; + resolved as — a plain granted account is a granted *account*, not a + write-everywhere global role; "global RFC Contributor" is an explicit + `scope_type='global'` grant; the implicit-public write baseline is + grandfathered onto the migration-seeded `default` collection only (N=1 + preserved). *Flag for the SPEC merge (S6): reinterprets §B.1/§B.3.* **Usable + end-state:** a user granted RFC Contributor at a scope can contribute across + exactly that subtree, Owners administer their subtree, and a collection can be + hidden from the public. **Completes:** `@S3` (all of C.1 — role usage, + inheritance, union, no-negative-override). + +- **S4 — Invitation surfaces + role-aware empty states.** The invite UI + (Owner-only) granting Owner/RFC Contributor at a scope or any scope beneath it, + with §15 notifications and the broader-scope-supersedes rule; the + create-first-collection / propose-first empty states keyed to the actor's role. + **Usable end-state:** an Owner invites collaborators at the right scope from + the UI. **Completes:** `@S4` (all of C.2 — invitation; plus the project/ + collection empty states C3.3–C3.5). + +- **S5 — In-app create-project + the global directory.** The global-Owner + **create-project** action (bot provisions a Gitea content repo + commits to + `projects.yaml`); the deployment directory empty states. **Usable end-state:** + a global Owner stands up a new project end-to-end from the UI. **Completes:** + `@S5` (the global-directory empty states C3.1–C3.2). + +- **S6 — Type modules, membership lifecycle, hardening, SPEC merge.** Per-type + frontmatter + surfaces selected on the **collection's** `type`; request-to-join + + cross-collection inbox; per-collection `enabled_models`; the registry + + manifest format in `docs/DEPLOYMENTS.md`; two-project / multi-collection e2e; + the §20.4 changelog + upgrade-steps; the SPEC merge (Part A applied, Part D in + place). **Usable end-state:** the model is fully realized and merged into + `SPEC.md`. **Completes:** type-specific scenarios (added in S6, beyond Part C's + role focus). + +### Slice → scenario index (the inverse of the `@S<n>` tags) + +| Slice | Usable thing it ships | Completes (`@S<n>`) | +|---|---|---| +| **S1** | collection grain + default + redirects; N=1 unchanged | C3.7, C3.8 (`@S1`) | +| **S2** | create + navigate + propose a 2nd collection | C3.6 (`@S2`) | +| **S3** | scope-role enforcement across global/project/collection | C1.1–C1.8 (`@S3`) | +| **S4** | invitation UI + role-aware empty states | C2.1–C2.7, C3.3–C3.5 (`@S4`) | +| **S5** | in-app create-project + global directory | C3.1, C3.2 (`@S5`) | +| **S6** | type surfaces, lifecycle, hardening, SPEC merge | type-specific (new) | + +Each slice is a candidate single session: it lands a usable deployment and a +runnable acceptance gate (`--tags @S<n>`). **S1 is the natural first session** — +the coupled migration 029 + threading + redirect, sized as one usable increment +(answering the in-session question: bundled, it is right-sized, not too much). + +## E.1 (= §A.6) Migration — the default collection (the N=1 case) + +A deployment on the shipped two-tier schema (v0.39.0) is migrated by 029 so it +keeps running unchanged: + +1. The existing `projects` row **stays as the project** (it already owns + `content_repo` and its config-derived `id` from §22.13 step 1). +2. A **default collection** (`id='default'`, `subfolder` = repo root) is created + per project, inheriting that project's `type` / `initial_state` / visibility; + those fields are then dropped from `projects`. +3. Every entry-scoped `project_id` row is re-keyed with the default + `collection_id` (PK `(project_id, slug)` → `(collection_id, slug)`). +4. `project_members` rows migrate to `memberships(scope_type='collection')` on + the default collection, role-collapsed (§B.3). +5. **308 redirects:** the shipped `/p/<project>/e/<slug>` → + `/p/<project>/c/<default>/e/<slug>`, and the pre-multi-project `/rfc/<slug>` + / `/proposals/<n>` → their `/p/<project>/c/<default>/…` equivalents. + +Until a second collection is added, the deployment is functionally identical to +before, with one extra path segment. This is the §20.4 upgrade-steps content for +the release. + +## E.2 Scope of the first implementation pass + +Per the launch ask — "we don't need to get all permissions right yet, just have +Owner at all levels, and RFC Contributor at the global, project, and RFC +collection level" — the **role surface** this pass implements is exactly +`{owner, contributor}` × `{global, project, collection}` (Part B), plus the +unchanged per-entry layer. `viewer`, the owner/admin split, request-to-join +nuances, and per-type role labels are deferred (§B.1 note). diff --git a/docs/design/plans/2026-06-05-s2-second-collection.md b/docs/design/plans/2026-06-05-s2-second-collection.md new file mode 100644 index 0000000..a0a7f6f --- /dev/null +++ b/docs/design/plans/2026-06-05-s2-second-collection.md @@ -0,0 +1,1210 @@ +# §22 S2 — Create & navigate a second collection — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to +> implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Let a deployment admin create a second RFC collection beside a project's +default one, navigate to it, and propose into it — completing acceptance scenario +C3.6 (`@S2`): an anonymous visitor landing on an empty public collection's catalog +sees an empty catalog with no propose action and a sign-in prompt. + +**Architecture:** S1 shipped the collection grain (migration 029): a `collections` +table, entries keyed `(collection_id, slug)`, one seeded `default` collection per +project. S2 makes collections *plural and navigable*. Backend: the registry mirror +learns to read `.collection.yaml` manifests inside each project's content repo; the +corpus mirror becomes collection-grained (reads each collection's `<subfolder>/rfcs/`); +a bot-commit-wrapped create-collection endpoint commits a manifest; collection-scoped +list/get/propose endpoints land under `/api/projects/:id/collections/:cid/…`. Frontend: +`/p/<project>/` becomes a collection directory (1 visible → redirect, keeping S1; 2+ → +list); the Catalog rail + entry views read the active `:collectionId` from the route. + +**Tech Stack:** FastAPI + sqlite (backend, `backend/app/`), pytest (`backend/tests/`), +React + react-router + vitest (`frontend/src/`), Gitea content repos via the Bot wrapper. + +**Content-repo layout convention (decided this slice):** a collection with subfolder +`S` stores its manifest at `S/.collection.yaml` and its entries at `S/rfcs/`. The +default collection has subfolder `''` → manifest is the project's `projects.yaml` +entry (no `.collection.yaml`), entries at `rfcs/`. This keeps the shipped default +corpus path unchanged. + +**Authorization (this slice):** create-collection is gated to a deployment +owner/admin (`auth.require_admin`). Scoped {owner, contributor} roles at the +collection axis land in S3 — out of scope here. + +--- + +## File structure + +**Backend (modify):** +- `backend/app/registry.py` — add `.collection.yaml` discovery + upsert to the mirror. +- `backend/app/collections.py` — add `list_collections`, `get_collection`, `subfolder_of`. +- `backend/app/cache.py` — make `refresh_meta_repo` iterate a project's collections. +- `backend/app/bot.py` — add `create_collection` (commit `<subfolder>/.collection.yaml`). +- `backend/app/api.py` — add collection-scoped list/get/propose; refactor propose to take a collection. +- `backend/app/api_collections.py` — **create**: GET list/one + POST create-collection endpoints. +- `backend/app/main.py` — mount the new router. + +**Frontend (modify):** +- `frontend/src/lib/entryPaths.js` — add `useCollectionId()`. +- `frontend/src/api.js` — collection-scoped `listRFCs`/`getRFC`/`proposeRFC`; add `listCollections`, `createCollection`. +- `frontend/src/components/Catalog.jsx` — read active collection; scope fetches + links. +- `frontend/src/App.jsx` — `/p/<project>/` → `CollectionDirectory`; thread collection into propose. +- `frontend/src/components/CollectionDirectory.jsx` — **create**: the directory page. + +**Tests (create):** +- `backend/tests/test_collection_registry.py` — `.collection.yaml` mirror. +- `backend/tests/test_collection_create_vertical.py` — create → mirror → list round-trip. +- `backend/tests/test_collection_scoped_serve.py` — list/get/propose under a named collection. +- `frontend/src/lib/entryPaths.test.js` (extend) + `frontend/src/components/CollectionDirectory.test.jsx`. + +--- + +## Task 1: Registry mirror reads `.collection.yaml` manifests + +**Files:** +- Modify: `backend/app/registry.py` +- Test: `backend/tests/test_collection_registry.py` + +The mirror today upserts only the default collection from `projects.yaml`. Add a +`parse_collection_manifest(text)` (pure) and extend `refresh_registry` to walk each +project's content repo root, read every `<subdir>/.collection.yaml`, and upsert a +collection row keyed by the subdir name. + +- [ ] **Step 1: Write the failing test** for the pure parser. + +```python +# backend/tests/test_collection_registry.py +import pytest +from app import registry + + +def test_parse_collection_manifest_minimal(): + doc = registry.parse_collection_manifest("type: bdd\n") + assert doc.type == "bdd" + # §22.4b: bdd defaults to 'active'; visibility inherits (None == inherit). + assert doc.initial_state == "active" + assert doc.visibility is None + assert doc.name is None + + +def test_parse_collection_manifest_full(): + doc = registry.parse_collection_manifest( + "type: document\nvisibility: public\ninitial_state: active\nname: Model\n" + ) + assert (doc.type, doc.visibility, doc.initial_state, doc.name) == ( + "document", "public", "active", "Model", + ) + + +def test_parse_collection_manifest_rejects_bad_type(): + with pytest.raises(registry.RegistryError): + registry.parse_collection_manifest("type: nonsense\n") +``` + +- [ ] **Step 2: Run it, verify it fails.** + +Run: `cd backend && python -m pytest tests/test_collection_registry.py -q` +Expected: FAIL (`parse_collection_manifest` not defined). + +- [ ] **Step 3: Implement `parse_collection_manifest` + `CollectionEntry`** in `registry.py`. + +```python +# Add near ProjectEntry (after line 56). +@dataclass +class CollectionEntry: + type: str + visibility: str | None # None == inherit the project's visibility + initial_state: str + name: str | None + + +def parse_collection_manifest(text: str) -> CollectionEntry: + """Parse + validate a `.collection.yaml`. Pure. Raises RegistryError.""" + raw = yaml.safe_load(text) or {} + if not isinstance(raw, dict): + raise RegistryError("collection manifest must be a mapping") + ctype = str(raw.get("type") or "").strip() + if ctype not in VALID_TYPES: + raise RegistryError(f"collection has invalid type {ctype!r}") + vis = raw.get("visibility") + if vis is not None: + vis = str(vis).strip() + if vis not in VALID_VISIBILITY: + raise RegistryError(f"collection has invalid visibility {vis!r}") + initial_state = str( + raw.get("initial_state") or _TYPE_DEFAULT_INITIAL_STATE[ctype] + ).strip() + if initial_state not in VALID_INITIAL_STATE: + raise RegistryError(f"collection has invalid initial_state {initial_state!r}") + name = raw.get("name") + name = str(name).strip() if name else None + return CollectionEntry(ctype, vis, initial_state, name) +``` + +- [ ] **Step 4: Run the parser tests, verify PASS.** + +Run: `cd backend && python -m pytest tests/test_collection_registry.py -q` +Expected: PASS (3 tests). + +- [ ] **Step 5: Write the failing mirror test** (uses a fake Gitea exposing the content repo). + +```python +# Append to backend/tests/test_collection_registry.py +import asyncio + + +class _FakeGitea: + """Minimal Gitea stub: projects.yaml in the registry repo + a content repo + holding one `.collection.yaml` under `features/`.""" + def __init__(self, projects_yaml, repo_tree): + self._projects_yaml = projects_yaml + self._repo_tree = repo_tree # {repo: {path: text}} + + async def get_contents(self, org, repo, path, ref="main"): + import base64 + if path == "projects.yaml": + enc = base64.b64encode(self._projects_yaml.encode()).decode() + return {"type": "file", "content": enc, "sha": "deadbeef"} + text = self._repo_tree.get(repo, {}).get(path) + if text is None: + return None + return {"type": "file", + "content": base64.b64encode(text.encode()).decode(), "sha": "c0ffee"} + + async def list_dir(self, org, repo, path, ref="main"): + # Root listing: surface each top-level subdir as a 'dir' entry. + names = set() + for p in self._repo_tree.get(repo, {}): + head = p.split("/", 1)[0] + if "/" in p: + names.add(head) + return [{"type": "dir", "name": n, "path": n} for n in sorted(names)] + + +def test_refresh_registry_mirrors_named_collection(app_with_db, config): + gitea = _FakeGitea( + projects_yaml=( + "deployment:\n name: Ohm\n tagline: t\n" + "projects:\n - id: ohm\n name: Ohm\n type: document\n" + " content_repo: ohm-rfc\n visibility: public\n" + ), + repo_tree={"ohm-rfc": {"features/.collection.yaml": "type: bdd\nname: Features\n"}}, + ) + asyncio.run(registry.refresh_registry(config, gitea)) + from app import db + row = db.conn().execute( + "SELECT type, subfolder, name, project_id FROM collections WHERE id = 'features'" + ).fetchone() + assert row is not None + assert (row["type"], row["subfolder"], row["project_id"]) == ("bdd", "features", "ohm") +``` + +> Use the existing test app/db fixture pattern — mirror `app_with_db` / `config` +> fixtures from `backend/tests/test_registry.py`. If that test uses different +> fixture names, copy its setup verbatim here. + +- [ ] **Step 6: Run it, verify it fails** (`features` collection not mirrored). + +Run: `cd backend && python -m pytest tests/test_collection_registry.py::test_refresh_registry_mirrors_named_collection -q` +Expected: FAIL (row is None). + +- [ ] **Step 7: Extend `refresh_registry`** to discover + upsert named collections. + +After `apply_registry(...)` in `refresh_registry` (registry.py line 215), add: + +```python + # §22 S2: named collections are declared by `.collection.yaml` manifests + # inside each project's content repo (the default collection comes from + # projects.yaml above). Walk each content repo root; a subdir carrying a + # manifest becomes a collection keyed by the subdir name. + for proj in doc.projects: + try: + items = await gitea.list_dir(config.gitea_org, proj.content_repo, "", ref="main") + except Exception as e: # GiteaError or transport — tolerate, keep last-good + log.warning("registry: cannot list %s root: %s", proj.content_repo, e) + continue + for it in items: + if it.get("type") != "dir": + continue + subdir = it["name"] + manifest = await gitea.get_contents( + config.gitea_org, proj.content_repo, f"{subdir}/.collection.yaml", ref="main" + ) + if not manifest or manifest.get("type") != "file": + continue + mtext = base64.b64decode(manifest["content"]).decode("utf-8") + try: + ce = parse_collection_manifest(mtext) + except RegistryError as e: + log.error("registry: bad manifest %s/%s: %s", proj.content_repo, subdir, e) + continue + _upsert_named_collection(proj, subdir, ce, sha) +``` + +And add the upsert helper (after `apply_registry`): + +```python +def _upsert_named_collection(proj: ProjectEntry, subdir: str, ce: CollectionEntry, sha: str) -> None: + """Upsert one named collection. Type is immutable (§22.4a): a type change + against an existing row is refused (logged, not applied). Visibility None + inherits the project's visibility.""" + visibility = ce.visibility or proj.visibility + with db.tx() as conn: + existing = conn.execute( + "SELECT type FROM collections WHERE id = ?", (subdir,) + ).fetchone() + if existing is not None and existing["type"] != ce.type: + log.error("registry: refusing immutable type change on collection %s (%s -> %s)", + subdir, existing["type"], ce.type) + return + 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, + initial_state = excluded.initial_state, + visibility = excluded.visibility, + name = excluded.name, + registry_sha = excluded.registry_sha, + updated_at = datetime('now') + """, + (subdir, proj.id, ce.type, subdir, ce.initial_state, visibility, ce.name, sha), + ) +``` + +- [ ] **Step 8: Run all of Task 1's tests, verify PASS.** + +Run: `cd backend && python -m pytest tests/test_collection_registry.py -q` +Expected: PASS (4 tests). + +- [ ] **Step 9: Commit.** + +```bash +git add backend/app/registry.py backend/tests/test_collection_registry.py +git commit -m "§22 S2: registry mirror reads .collection.yaml manifests" +``` + +--- + +## Task 2: Collection read helpers — `list_collections`, `get_collection`, `subfolder_of` + +**Files:** +- Modify: `backend/app/collections.py` +- Test: `backend/tests/test_collection_create_vertical.py` (first assertions) + +- [ ] **Step 1: Write the failing test.** + +```python +# backend/tests/test_collection_create_vertical.py +from app import db, collections as collections_mod + + +def _seed(project_id="ohm"): + db.conn().execute( + "INSERT INTO projects (id, name, content_repo, visibility, updated_at) " + "VALUES (?, 'Ohm', 'ohm-rfc', 'public', datetime('now'))", (project_id,)) + for cid, sub, vis, name in [ + ("default", "", "public", "Model"), + ("features", "features", "public", "Features"), + ("secret", "secret", "unlisted", "Secret"), + ]: + db.conn().execute( + "INSERT INTO collections (id, project_id, type, subfolder, initial_state, " + "visibility, name, created_at, updated_at) VALUES (?,?, 'document', ?, " + "'super-draft', ?, ?, datetime('now'), datetime('now'))", + (cid, project_id, sub, vis, name)) + + +def test_list_collections_excludes_unlisted(app_with_db): + _seed() + ids = [c["id"] for c in collections_mod.list_collections("ohm", include_unlisted=False)] + assert ids == ["default", "features"] # 'secret' (unlisted) omitted + + +def test_get_collection_and_subfolder(app_with_db): + _seed() + assert collections_mod.get_collection("features")["name"] == "Features" + assert collections_mod.subfolder_of("features") == "features" + assert collections_mod.subfolder_of("default") == "" + assert collections_mod.get_collection("nope") is None +``` + +- [ ] **Step 2: Run it, verify it fails.** + +Run: `cd backend && python -m pytest tests/test_collection_create_vertical.py -q` +Expected: FAIL (`list_collections` not defined). + +- [ ] **Step 3: Implement the helpers** in `collections.py`. + +```python +def get_collection(collection_id: str) -> dict | None: + row = db.conn().execute( + "SELECT id, project_id, type, subfolder, initial_state, visibility, name " + "FROM collections WHERE id = ?", (collection_id,) + ).fetchone() + return dict(row) if row else None + + +def subfolder_of(collection_id: str) -> str: + row = db.conn().execute( + "SELECT subfolder FROM collections WHERE id = ?", (collection_id,) + ).fetchone() + return (row["subfolder"] if row else "") or "" + + +def list_collections(project_id: str, include_unlisted: bool = False) -> list[dict]: + """Collections in a project, default first then by name. `unlisted` is + omitted from enumeration unless include_unlisted (a direct-id read).""" + rows = db.conn().execute( + "SELECT id, project_id, type, subfolder, initial_state, visibility, name " + "FROM collections WHERE project_id = ? ORDER BY (id != 'default'), name, id", + (project_id,), + ).fetchall() + out = [] + for r in rows: + if not include_unlisted and r["visibility"] == "unlisted": + continue + out.append(dict(r)) + return out +``` + +- [ ] **Step 4: Run it, verify PASS.** + +Run: `cd backend && python -m pytest tests/test_collection_create_vertical.py -q` +Expected: PASS (2 tests). + +- [ ] **Step 5: Commit.** + +```bash +git add backend/app/collections.py backend/tests/test_collection_create_vertical.py +git commit -m "§22 S2: collection read helpers (list/get/subfolder)" +``` + +--- + +## Task 3: Corpus mirror is collection-grained + +**Files:** +- Modify: `backend/app/cache.py:37-97` +- Test: `backend/tests/test_collection_scoped_serve.py` (first assertion) + +`refresh_meta_repo` reads only `rfcs/` (the default collection). Make it iterate a +project's collections and read each collection's `<subfolder>/rfcs/`, keying +`cached_rfcs` by `collection_id`. + +- [ ] **Step 1: Write the failing test.** + +```python +# backend/tests/test_collection_scoped_serve.py +import asyncio +from app import db, cache + + +class _CorpusGitea: + """A content repo with entries under both the default `rfcs/` and a named + collection's `features/rfcs/`.""" + def __init__(self, tree): + self._tree = tree # {path: text} + + async def list_dir(self, org, repo, path, ref="main"): + out = [] + prefix = (path.rstrip("/") + "/") if path else "" + for p in self._tree: + if p.startswith(prefix) and "/" not in p[len(prefix):]: + out.append({"type": "file", "name": p.split("/")[-1], "path": p}) + return out + + async def read_file(self, org, repo, path, ref="main"): + t = self._tree.get(path) + return (t, "sha-" + path) if t is not None else None + + +def _entry_md(slug, title): + return f"---\nslug: {slug}\ntitle: {title}\nstate: active\n---\nbody\n" + + +def test_mirror_keys_entries_by_collection(app_with_db, config): + db.conn().execute( + "INSERT INTO projects (id, name, content_repo, visibility, updated_at) " + "VALUES ('ohm','Ohm','ohm-rfc','public', datetime('now'))") + for cid, sub in [("default", ""), ("features", "features")]: + db.conn().execute( + "INSERT INTO collections (id, project_id, type, subfolder, initial_state, " + "visibility, created_at, updated_at) VALUES (?, 'ohm','document',?, " + "'super-draft','public', datetime('now'), datetime('now'))", (cid, sub)) + gitea = _CorpusGitea({ + "rfcs/a.md": _entry_md("a", "Default A"), + "features/rfcs/b.md": _entry_md("b", "Feature B"), + }) + asyncio.run(cache.refresh_meta_repo(config, gitea)) + got = {(r["collection_id"], r["slug"]) for r in + db.conn().execute("SELECT collection_id, slug FROM cached_rfcs")} + assert got == {("default", "a"), ("features", "b")} +``` + +- [ ] **Step 2: Run it, verify it fails** (entry `b` mirrored to wrong/absent collection). + +Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py::test_mirror_keys_entries_by_collection -q` +Expected: FAIL. + +- [ ] **Step 3: Rework `refresh_meta_repo` + `_refresh_project_corpus`** in `cache.py`. + +Replace the body of `_refresh_project_corpus` so it loops the project's collections: + +```python +async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None: + # §22 S2: the corpus grain is the collection. Mirror each collection of the + # project from its `<subfolder>/rfcs/` directory, keying cached_rfcs by the + # collection id. The default collection (subfolder '') reads `rfcs/`. + from . import collections as collections_mod + for col in collections_mod.list_collections(project_id, include_unlisted=True): + collection_id = col["id"] + sub = col["subfolder"] or "" + rfcs_dir = f"{sub}/rfcs" if sub else "rfcs" + try: + files = await gitea.list_dir(org, repo, rfcs_dir, ref="main") + except GiteaError as e: + log.warning("refresh_meta_repo: %s/%s: cannot list %s: %s", + project_id, collection_id, rfcs_dir, e) + continue + seen_slugs: set[str] = set() + for f in files: + if f.get("type") != "file" or not f.get("name", "").endswith(".md"): + continue + result = await gitea.read_file(org, repo, f["path"], ref="main") + if not result: + continue + text, sha = result + try: + entry = entry_mod.parse(text) + except Exception as parse_err: + log.warning("refresh_meta_repo: %s/%s: skipping %s: %s", + project_id, collection_id, f["path"], parse_err) + continue + if not entry.slug: + continue + seen_slugs.add(entry.slug) + _upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id) + existing = {row["slug"] for row in db.conn().execute( + "SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,))} + for missing in existing - seen_slugs: + log.info("refresh_meta_repo: %s/%s/%s no longer present — leaving cache row", + project_id, collection_id, missing) +``` + +> `refresh_meta_repo` itself (the project loop) is unchanged — it still calls +> `_refresh_project_corpus(org, id, content_repo, gitea)` per project. + +- [ ] **Step 4: Run it, verify PASS.** + +Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py::test_mirror_keys_entries_by_collection -q` +Expected: PASS. + +- [ ] **Step 5: Run the full backend suite** — the default-collection path must be unchanged. + +Run: `cd backend && python -m pytest -q` +Expected: PASS (all green; N=1 default path intact). + +- [ ] **Step 6: Commit.** + +```bash +git add backend/app/cache.py backend/tests/test_collection_scoped_serve.py +git commit -m "§22 S2: corpus mirror reads each collection's <subfolder>/rfcs/" +``` + +--- + +## Task 4: Collection-scoped serve + propose endpoints (backend) + +**Files:** +- Modify: `backend/app/api.py` — refactor propose to accept a collection; add scoped list/get/propose. +- Test: `backend/tests/test_collection_scoped_serve.py` (append) + +The shipped `_propose_into_project` hardcodes `collection_id = default_collection_id(project_id)` +(api.py:973). Generalize it to accept an explicit collection, and write the entry into +that collection's `<subfolder>/rfcs/`. + +- [ ] **Step 1: Write the failing test** (scoped list + propose targets the named collection). + +```python +# Append to backend/tests/test_collection_scoped_serve.py +def test_scoped_list_returns_only_that_collection(client_with_corpus): + client, _ = client_with_corpus # fixture seeds 'default'+'features' w/ entries a,b + r = client.get("/api/projects/ohm/collections/features/rfcs") + assert r.status_code == 200 + assert [i["slug"] for i in r.json()["items"]] == ["b"] + + +def test_scoped_propose_writes_into_collection_subfolder(client_with_corpus): + client, fake_bot = client_with_corpus + r = client.post("/api/projects/ohm/collections/features/rfcs/propose", + json={"title": "New", "slug": "newb", "pitch": "x", "tags": []}) + assert r.status_code == 200 + # The bot was asked to write under features/rfcs/, not rfcs/. + assert fake_bot.last_meta_path_prefix == "features/rfcs" +``` + +> Build `client_with_corpus` from the existing `app_with_fake_gitea` / +> propose-vertical fixture in `backend/tests/test_propose_vertical.py`; extend its +> fake bot to record the subfolder it was handed (see Step 3). If the propose-vertical +> fixture is structured differently, follow its shape and adapt these two asserts. + +- [ ] **Step 2: Run it, verify it fails.** + +Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py -q` +Expected: FAIL (route 404 / propose ignores collection). + +- [ ] **Step 3: Generalize `_propose_into_project` → `_propose_into_collection`** in api.py. + +Change the signature and the two collection-dependent lines. The current helper +(api.py:959) resolves `collection_id` from the project default; instead take it as a +parameter and prefix the bot write path with the collection's subfolder: + +```python + async def _propose_into_collection( + project_id: str, collection_id: str, payload: ProposeBody, user + ) -> dict[str, Any]: + if not auth.can_contribute_in_project(user, project_id): + raise HTTPException(403, "You do not have contribute access to this project") + slug = payload.slug.strip().lower() + if not entry_mod.is_valid_slug(slug): + raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes") + clash = db.conn().execute( + "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") + # ...unchanged idea_clash / entry build... + landing_state = ( + "active" if collections_mod.collection_initial_state(collection_id) == "active" + else "super-draft" + ) + # ...build `entry`, `contents`, `pr_title`, `pr_description` unchanged... + subfolder = collections_mod.subfolder_of(collection_id) + rfcs_prefix = f"{subfolder}/rfcs" if subfolder else "rfcs" + pr = await bot.open_idea_pr( + user.as_actor(), + org=config.gitea_org, + meta_repo=(projects_mod.content_repo(project_id) or ""), + slug=slug, + file_contents=contents, + pr_title=pr_title, + pr_description=pr_description, + rfcs_dir=rfcs_prefix, + ) + # ...refresh + use_case persistence: pass collection_id (it already does)... +``` + +> Keep `_propose_into_project(project_id, payload, user)` as a thin wrapper that +> resolves the default collection and calls `_propose_into_collection`, so the +> existing `/api/rfcs/propose` and `/api/projects/:id/rfcs/propose` routes are +> unchanged. `landing_state` now derives from the collection (replacing the +> `projects_mod.project_initial_state` line at api.py:991). + +- [ ] **Step 4: Add `rfcs_dir` param to `bot.open_idea_pr`** (default `"rfcs"`), threading it + into the file path it writes. In `backend/app/bot.py`, `open_idea_pr` (≈line 168) builds the + entry file path as `f"rfcs/{slug}.md"`; change to accept `rfcs_dir: str = "rfcs"` and build + `f"{rfcs_dir}/{slug}.md"`. Leave every existing caller (which omits the arg) on `"rfcs"`. + +- [ ] **Step 5: Add the scoped read + propose routes** in api.py (beside the existing project routes). + +```python + @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs") + async def list_collection_rfcs(project_id: str, collection_id: str, request: Request, + unreviewed: str | None = None) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + _require_collection_in_project(collection_id, project_id) + return _list_rfcs_for_collection(collection_id, viewer, unreviewed) + + @router.get("/api/projects/{project_id}/collections/{collection_id}/rfcs/{slug}") + async def get_collection_rfc(project_id: str, collection_id: str, slug: str, + request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + _require_collection_in_project(collection_id, project_id) + return _get_rfc_for_collection(collection_id, slug, viewer) + + @router.post("/api/projects/{project_id}/collections/{collection_id}/rfcs/propose") + async def propose_collection_rfc(project_id: str, collection_id: str, + payload: ProposeBody, request: Request) -> dict[str, Any]: + user = auth.require_contributor(request) + auth.require_project_readable(user, project_id) + _require_collection_in_project(collection_id, project_id) + return await _propose_into_collection(project_id, collection_id, payload, user) +``` + +Add the small helpers (refactor the bodies of the existing `list_project_rfcs` / +`get_project_rfc` at api.py:720-795 into `_list_rfcs_for_collection(collection_id, viewer, unreviewed)` +and `_get_rfc_for_collection(collection_id, slug, viewer)`, then have the project-scoped +routes call them with the default collection). Add: + +```python + def _require_collection_in_project(collection_id: str, project_id: str) -> None: + if collections_mod.project_of_collection(collection_id) != project_id: + raise HTTPException(404, "Not found") +``` + +- [ ] **Step 6: Run the scoped-serve tests, verify PASS.** + +Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py -q` +Expected: PASS. + +- [ ] **Step 7: Run the full backend suite, verify PASS** (default-collection routes unchanged). + +Run: `cd backend && python -m pytest -q` +Expected: PASS. + +- [ ] **Step 8: Commit.** + +```bash +git add backend/app/api.py backend/app/bot.py backend/tests/test_collection_scoped_serve.py +git commit -m "§22 S2: collection-scoped list/get/propose endpoints" +``` + +--- + +## Task 5: create-collection endpoint (bot commit + registry refresh) + +**Files:** +- Create: `backend/app/api_collections.py` +- Modify: `backend/app/bot.py` (add `create_collection`), `backend/app/main.py` (mount router). +- Test: `backend/tests/test_collection_create_vertical.py` (append) + +Flow: deployment admin POSTs `{collection_id, type, name?, visibility?, initial_state?}`; +the bot commits `<collection_id>/.collection.yaml` to the project's content-repo `main`; +then `refresh_registry` re-reads and upserts the collection row. The registry stays the +source of truth (§22.2) — the endpoint never writes the `collections` row directly. + +- [ ] **Step 1: Write the failing vertical test.** + +```python +# Append to backend/tests/test_collection_create_vertical.py +def test_create_collection_commits_manifest_and_mirrors(admin_client_with_fake_gitea): + client, fake = admin_client_with_fake_gitea # seeds project 'ohm' + default collection + r = client.post("/api/projects/ohm/collections", + json={"collection_id": "features", "type": "bdd", "name": "Features"}) + assert r.status_code == 200, r.text + assert fake.committed_path == "features/.collection.yaml" + assert "type: bdd" in fake.committed_text + # registry refresh ran → row exists. + row = db.conn().execute("SELECT type, project_id FROM collections WHERE id='features'").fetchone() + assert (row["type"], row["project_id"]) == ("bdd", "ohm") + + +def test_create_collection_requires_admin(member_client): + r = member_client.post("/api/projects/ohm/collections", + json={"collection_id": "x", "type": "bdd"}) + assert r.status_code in (401, 403) + + +def test_create_collection_rejects_duplicate_id(admin_client_with_fake_gitea): + client, _ = admin_client_with_fake_gitea + client.post("/api/projects/ohm/collections", json={"collection_id": "features", "type": "bdd"}) + r = client.post("/api/projects/ohm/collections", json={"collection_id": "features", "type": "bdd"}) + assert r.status_code == 409 +``` + +> Reuse the `app_with_fake_gitea` admin fixture from `test_propose_vertical.py`; +> extend its fake bot/Gitea to record `committed_path` / `committed_text` for a +> root-level file create, and to make `refresh_registry`'s `list_dir` + `get_contents` +> see the just-committed manifest (an in-memory tree the fake mutates on commit). + +- [ ] **Step 2: Run it, verify it fails.** + +Run: `cd backend && python -m pytest tests/test_collection_create_vertical.py -k create_collection -q` +Expected: FAIL (route 404). + +- [ ] **Step 3: Add `Bot.create_collection`** in `bot.py` (commit the manifest to main). + +```python + async def create_collection(self, actor, *, org: str, content_repo: str, + collection_id: str, manifest_yaml: str) -> None: + """Commit `<collection_id>/.collection.yaml` to the content repo's main. + A structural admin action — commits straight to main (no PR), like + registry config. Logs an action row for the audit trail.""" + path = f"{collection_id}/.collection.yaml" + await self._gitea.create_file( + org, content_repo, path, manifest_yaml, + message=self._stamp(actor, f"chore: create collection {collection_id}"), + branch="main", + ) + self._log(actor, action="create_collection", + details={"collection_id": collection_id, "repo": content_repo}) +``` + +> Match the exact `create_file` signature + `_stamp`/`_log` call shapes used by the +> neighbouring bot methods (e.g. `commit_accepted_change`); names above are +> illustrative of the pattern, not necessarily the literal argument names. + +- [ ] **Step 4: Create `backend/app/api_collections.py`** with the read + create routes. + +```python +"""§22 S2 — collection directory + create-collection. + +GET /api/projects/:id/collections — list visible collections. +GET /api/projects/:id/collections/:cid — one collection's settings. +POST /api/projects/:id/collections — create a collection (admin; bot commits + a `.collection.yaml`, then the registry + mirror upserts the row — §22.2). +""" +from __future__ import annotations + +import re +from typing import Any + +import yaml +from fastapi import APIRouter, HTTPException, Request +from pydantic import BaseModel + +from . import auth, bot, cache, collections as collections_mod, db, gitea as gitea_mod +from . import projects as projects_mod, registry as registry_mod +from .config import Config +from .gitea import GiteaError + +_SLUG_RE = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$") + + +class CreateCollectionBody(BaseModel): + collection_id: str + type: str + name: str | None = None + visibility: str | None = None + initial_state: str | None = None + + +def make_router(config: Config, gitea: gitea_mod.Gitea) -> APIRouter: + router = APIRouter() + + @router.get("/api/projects/{project_id}/collections") + async def list_cols(project_id: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + return {"items": collections_mod.list_collections(project_id)} + + @router.get("/api/projects/{project_id}/collections/{collection_id}") + async def get_col(project_id: str, collection_id: str, request: Request) -> dict[str, Any]: + viewer = auth.current_user(request) + auth.require_project_readable(viewer, project_id) + col = collections_mod.get_collection(collection_id) + if col is None or col["project_id"] != project_id: + raise HTTPException(404, "Not found") + return col + + @router.post("/api/projects/{project_id}/collections") + async def create_col(project_id: str, body: CreateCollectionBody, + request: Request) -> dict[str, Any]: + user = auth.require_admin(request) # deployment owner/admin (S2; scoped roles in S3) + auth.require_project_readable(user, project_id) + cid = body.collection_id.strip().lower() + if not _SLUG_RE.match(cid) or cid == "default": + raise HTTPException(422, "collection id must be a slug and not 'default'") + if body.type not in registry_mod.VALID_TYPES: + raise HTTPException(422, f"invalid type {body.type!r}") + if collections_mod.get_collection(cid) is not None: + raise HTTPException(409, f"collection `{cid}` already exists") + content_repo = projects_mod.content_repo(project_id) + if not content_repo: + raise HTTPException(409, "project has no content repo") + manifest: dict[str, Any] = {"type": body.type} + if body.name: + manifest["name"] = body.name + if body.visibility: + manifest["visibility"] = body.visibility + if body.initial_state: + manifest["initial_state"] = body.initial_state + manifest_yaml = yaml.safe_dump(manifest, sort_keys=False) + try: + await bot.create_collection( + user.as_actor(), org=config.gitea_org, content_repo=content_repo, + collection_id=cid, manifest_yaml=manifest_yaml) + except GiteaError as e: + raise HTTPException(502, f"Gitea: {e.detail}") + # §22.2: the registry mirror is the source of truth — re-read so the new + # manifest becomes a collections row. + await registry_mod.refresh_registry(config, gitea) + col = collections_mod.get_collection(cid) + if col is None: + raise HTTPException(500, "collection committed but not mirrored") + return col + + return router +``` + +> `bot.create_collection` is module-level if the codebase exposes `bot.<verb>` +> module functions (as the propose path uses `bot.open_idea_pr`); if `Bot` is a +> class instance, call it the same way the propose route does. Match the existing +> convention exactly. + +- [ ] **Step 5: Mount the router** in `backend/app/main.py` next to the other `make_router` mounts. + +```python + from . import api_collections + app.include_router(api_collections.make_router(config, gitea)) +``` + +- [ ] **Step 6: Run the create-collection tests, verify PASS.** + +Run: `cd backend && python -m pytest tests/test_collection_create_vertical.py -q` +Expected: PASS. + +- [ ] **Step 7: Run the full backend suite, verify PASS.** + +Run: `cd backend && python -m pytest -q` +Expected: PASS. + +- [ ] **Step 8: Commit.** + +```bash +git add backend/app/api_collections.py backend/app/bot.py backend/app/main.py backend/tests/test_collection_create_vertical.py +git commit -m "§22 S2: create-collection endpoint (bot commit + registry refresh)" +``` + +--- + +## Task 6: Frontend — collection-scoped path + API helpers + +**Files:** +- Modify: `frontend/src/lib/entryPaths.js`, `frontend/src/api.js` +- Test: `frontend/src/lib/entryPaths.test.js` (extend) + +- [ ] **Step 1: Write the failing test** for `useCollectionId` fallback + scoped API URLs. + +```js +// Append to frontend/src/lib/entryPaths.test.js +import { describe, it, expect } from 'vitest' +import { DEFAULT_COLLECTION } from './entryPaths' + +describe('collection paths', () => { + it('entryPath honors an explicit collection', () => { + // entryPath(pid, slug, cid) + const { entryPath } = require('./entryPaths') + expect(entryPath('ohm', 'a', 'features')).toBe('/p/ohm/c/features/e/a') + expect(entryPath('ohm', 'a')).toBe(`/p/ohm/c/${DEFAULT_COLLECTION}/e/a`) + }) +}) +``` + +> Match the file's existing import/runner style (it may use ESM `import` rather +> than `require`); follow whatever `entryPaths.test.js` already does. + +- [ ] **Step 2: Run it, verify PASS or FAIL** (entryPath already supports cid → this asserts the + contract; the new piece is `useCollectionId`). If green, proceed; the behavioral gap is the hook. + +Run: `cd frontend && npx vitest run src/lib/entryPaths.test.js` + +- [ ] **Step 3: Add `useCollectionId`** to `entryPaths.js`. + +```js +import { useParams } from 'react-router-dom' + +// The collection id a component should scope to: the `/c/:collectionId/` segment +// when present, else the project's default collection. +export function useCollectionId() { + const { collectionId } = useParams() + return collectionId || DEFAULT_COLLECTION +} +``` + +- [ ] **Step 4: Make `api.js` collection-aware.** Add optional `collectionId` to the corpus reads + and propose; add `listCollections` + `createCollection`. + +```js +export async function listRFCs(projectId, collectionId) { + if (projectId && collectionId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`)) + } + const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs' + return jsonOrThrow(await fetch(url)) +} + +export async function getRFC(projectId, slug, collectionId) { + if (collectionId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`)) + } + if (slug === undefined) return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`)) + return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`)) +} + +export async function listCollections(projectId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`)) +} + +export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) { + const res = await fetch(`/api/projects/${projectId}/collections`, { + method: 'POST', headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ collection_id: collectionId, type, name: name || null, + visibility: visibility || null, initial_state: initialState || null }), + }) + return jsonOrThrow(res) +} +``` + + And add an optional `collectionId` to `proposeRFC` so it targets the scoped route: + +```js +export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) { + const url = (projectId && collectionId) + ? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose` + : (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose') + // ...unchanged body/post... +} +``` + +- [ ] **Step 5: Run the frontend unit tests, verify PASS.** + +Run: `cd frontend && npx vitest run src/lib/entryPaths.test.js` +Expected: PASS. + +- [ ] **Step 6: Commit.** + +```bash +git add frontend/src/lib/entryPaths.js frontend/src/api.js frontend/src/lib/entryPaths.test.js +git commit -m "§22 S2: collection-scoped frontend path + API helpers" +``` + +--- + +## Task 7: Frontend — Catalog reads the active collection + +**Files:** +- Modify: `frontend/src/components/Catalog.jsx`, `frontend/src/App.jsx` (propose wiring) + +- [ ] **Step 1: Scope the Catalog to the active collection.** In `Catalog.jsx`: + - import `useCollectionId` from `../lib/entryPaths`; + - `const cid = useCollectionId()`; + - fetch `listRFCs(pid, cid)` and add `cid` to the effect deps; + - build entry links with `entryPath(pid, r.slug, cid)` and `proposalPath(pid, p.pr_number, cid)`. + + The anonymous empty-state (C3.6) is already correct — the footer renders the + "Sign in to propose" link for `!viewer`, and the empty list shows "No RFCs in the + catalog yet." Confirm both render for an anonymous viewer on an empty collection. + +- [ ] **Step 2: Thread the active collection into the propose modal** in `App.jsx`. The + `ProposeModal` is mounted with `projectId={currentProjectId}`; also pass the active + collection (derive from the route — read `useParams().collectionId` in the `AppShell` + scope, default `DEFAULT_COLLECTION`) and pass it to `proposeRFC` so a propose from a + named collection targets that collection. On submit, navigate with `proposalPath(currentProjectId, pr_number, currentCollectionId)`. + +- [ ] **Step 3: Build + run the frontend test suite, verify PASS.** + +Run: `cd frontend && npx vitest run` +Expected: PASS (existing tests green; default-collection behavior unchanged). + +- [ ] **Step 4: Commit.** + +```bash +git add frontend/src/components/Catalog.jsx frontend/src/App.jsx +git commit -m "§22 S2: Catalog + propose scoped to the active collection" +``` + +--- + +## Task 8: Frontend — the collection directory at `/p/<project>/` + +**Files:** +- Create: `frontend/src/components/CollectionDirectory.jsx` +- Modify: `frontend/src/App.jsx` (swap `DefaultCollectionRedirect` → `CollectionDirectory`) +- Test: `frontend/src/components/CollectionDirectory.test.jsx` + +- [ ] **Step 1: Write the failing test.** + +```jsx +// frontend/src/components/CollectionDirectory.test.jsx +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter } from 'react-router-dom' +import { describe, it, expect, vi } from 'vitest' +import CollectionDirectory from './CollectionDirectory' + +vi.mock('../api', () => ({ + listCollections: vi.fn(async () => ({ items: [ + { id: 'default', name: 'Model', type: 'document' }, + { id: 'features', name: 'Features', type: 'bdd' }, + ] })), +})) + +describe('CollectionDirectory', () => { + it('lists collections when there are 2+', async () => { + render(<MemoryRouter initialEntries={["/p/ohm/"]}><CollectionDirectory projectId="ohm" /></MemoryRouter>) + await waitFor(() => expect(screen.getByText('Features')).toBeInTheDocument()) + expect(screen.getByText('Model')).toBeInTheDocument() + }) +}) +``` + +> Match the project's component-test conventions (see `Directory.test.jsx` / +> `ProjectLayout.test.jsx` for the render + mock pattern; adapt the mock + queries +> to whatever they use). + +- [ ] **Step 2: Run it, verify it fails** (component missing). + +Run: `cd frontend && npx vitest run src/components/CollectionDirectory.test.jsx` +Expected: FAIL. + +- [ ] **Step 3: Implement `CollectionDirectory.jsx`.** Fetch `listCollections(projectId)`; when + exactly one visible collection, `<Navigate>` to its `collectionHome` (preserves the S1 + C3.7/C3.8 single-collection redirect); when 2+, render a list of links to each + `collectionHome(projectId, c.id)` with its name + type. (The "Create your first + collection" empty-state is S4 — for S2 a 0-collection project simply shows a minimal + "No collections yet." line; the create affordance UI lands in S4.) + +```jsx +import { useEffect, useState } from 'react' +import { Link, Navigate } from 'react-router-dom' +import { listCollections } from '../api' +import { collectionHome } from '../lib/entryPaths' + +export default function CollectionDirectory({ projectId }) { + const [cols, setCols] = useState(null) + useEffect(() => { + let live = true + listCollections(projectId).then(d => { if (live) setCols(d.items) }).catch(() => live && setCols([])) + return () => { live = false } + }, [projectId]) + if (cols === null) return <main className="chrome-pane"><div className="boot">Loading…</div></main> + if (cols.length === 1) return <Navigate to={collectionHome(projectId, cols[0].id)} replace /> + return ( + <main className="chrome-pane"> + <div className="collection-directory"> + <h1>Collections</h1> + {cols.length === 0 ? ( + <p>No collections yet.</p> + ) : ( + <ul> + {cols.map(c => ( + <li key={c.id}> + <Link to={collectionHome(projectId, c.id)}>{c.name || c.id}</Link> + <span className="collection-type"> · {c.type}</span> + </li> + ))} + </ul> + )} + </div> + </main> + ) +} +``` + +- [ ] **Step 4: Wire it into `App.jsx`.** Replace the `DefaultCollectionRedirect` route element + at the project landing (App.jsx:365) with `<CollectionDirectory projectId={<the route projectId>} />`. + Read the project id from `useParams()` inside a small wrapper (mirroring how + `DefaultCollectionRedirect` reads it), and keep `DefaultCollectionRedirect` only if still + referenced elsewhere (otherwise delete it). The S1 single-collection redirect now lives + inside `CollectionDirectory`, so the C3.7/C3.8 behavior is preserved. + +- [ ] **Step 5: Run the directory test + full frontend suite, verify PASS.** + +Run: `cd frontend && npx vitest run` +Expected: PASS. + +- [ ] **Step 6: Commit.** + +```bash +git add frontend/src/components/CollectionDirectory.jsx frontend/src/components/CollectionDirectory.test.jsx frontend/src/App.jsx +git commit -m "§22 S2: collection directory at /p/<project>/ (1 → redirect, 2+ → list)" +``` + +--- + +## Task 9: Acceptance — C3.6 anonymous empty-collection catalog + +**Files:** +- Test: `backend/tests/test_collection_scoped_serve.py` (append the `@S2` acceptance assertion) + +The `@S2` gate is C3.6: *a public collection with no entries; an anonymous visitor +lands on `/p/ohm/c/model/`; they see an empty catalog with no propose action and a +sign-in prompt.* Backend half: the scoped list returns `{items: []}` for an anonymous +viewer on a public empty collection (no 404, no propose surfaced). Frontend half: the +Catalog footer renders "Sign in to propose" for `!viewer` (already covered by Task 7). + +- [ ] **Step 1: Write the acceptance test** (backend contract for the empty public collection). + +```python +def test_s2_anonymous_empty_public_collection(client_with_corpus): + """C3.6 (@S2): anonymous viewer, public empty collection → empty catalog, 200.""" + client, _ = client_with_corpus # ensure an empty public collection 'model' + r = client.get("/api/projects/ohm/collections/model/rfcs") # no auth header + assert r.status_code == 200 + assert r.json()["items"] == [] +``` + +> If `client_with_corpus` doesn't already seed an empty public `model` collection, +> add one (mirror Task 3's seed). The propose-absence is enforced by `require_contributor` +> on the propose route (anonymous → 401/403) and the Catalog UI footer. + +- [ ] **Step 2: Run it, verify PASS.** + +Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py::test_s2_anonymous_empty_public_collection -q` +Expected: PASS. + +- [ ] **Step 3: Manually verify the propose route rejects anonymous** (sanity — no new code): + +Run: `cd backend && python -m pytest tests/test_collection_scoped_serve.py -q` +Expected: PASS (all). + +- [ ] **Step 4: Commit.** + +```bash +git add backend/tests/test_collection_scoped_serve.py +git commit -m "§22 S2: @S2 acceptance — anonymous empty public collection catalog" +``` + +--- + +## Task 10: Release — version bump, changelog, docs + +**Files:** +- Modify: `VERSION`, `frontend/package.json`, `CHANGELOG.md` +- Modify: `docs/design/2026-06-05-three-tier-projects-collections.md` (mark S2 shipped) + +S2 adds functionality and is non-breaking (new optional path segment + new endpoints; +the default-collection paths are unchanged). Per SPEC §20, that's a **minor** bump: +`0.40.0` → `0.41.0`. + +- [ ] **Step 1: Bump `VERSION`** to `0.41.0`. + +- [ ] **Step 2: Bump `frontend/package.json#version`** to `0.41.0` (must mirror VERSION — §20). + +- [ ] **Step 3: Add the `CHANGELOG.md` entry** under a new `## 0.41.0` heading: a minor + release shipping S2 (create + navigate + propose a second collection): registry mirror + reads `.collection.yaml`; collection-grained corpus mirror; create-collection endpoint; + collection-scoped list/get/propose; `/p/<project>/` collection directory. Note it + completes `@S2` (C3.6). No upgrade steps required (additive; existing default-collection + deployments keep working unchanged) — state that explicitly. + +- [ ] **Step 4: Mark S2 shipped** in the design doc's Part E slice list (a short + "Landed vX" note on the S2 bullet, mirroring how S1 was annotated). + +- [ ] **Step 5: Run both suites once more, verify green.** + +Run: `cd backend && python -m pytest -q && cd ../frontend && npx vitest run` +Expected: PASS (both). + +- [ ] **Step 6: Commit.** + +```bash +git add VERSION frontend/package.json CHANGELOG.md docs/design/2026-06-05-three-tier-projects-collections.md +git commit -m "§22 S2: release v0.41.0 — create & navigate a second collection (@S2)" +``` + +--- + +## Self-review notes + +- **Spec coverage (Part E "S2" bullet):** registry reads `.collection.yaml` (Task 1) · + create-collection endpoint, admin-gated (Task 5) · project collection-directory at + `/p/<project>/` (Task 8) · collection-scoped propose/serve (Tasks 3,4,7) · `@S2` / C3.6 + acceptance (Task 9). ✓ +- **N=1 unchanged invariant:** every backend task ends by running the full suite; the + default-collection routes are preserved as thin wrappers over the new collection-scoped + internals. ✓ +- **Type consistency:** `collection_id`/`subfolder` naming is uniform; the entries path is + `<subfolder>/rfcs/` everywhere (cache mirror Task 3, propose Task 4, create manifest Task 5). +- **Deferred / lower-confidence calls (log to transcript):** (a) create-collection commits + straight to `main` rather than via a PR — chosen because a collection is structural config + and the registry mirror is the source of truth; (b) S2 collection visibility filtering is + coarse (project read-gate + drop `unlisted`); full scope-role enforcement is S3; (c) the + per-collection "create"/"propose-first" *empty-state affordances* are S4 — S2 ships only the + anonymous empty catalog (C3.6) and a minimal directory. diff --git a/docs/superpowers/plans/2026-06-05-s1-three-tier-collection-grain.md b/docs/superpowers/plans/2026-06-05-s1-three-tier-collection-grain.md new file mode 100644 index 0000000..a0a733b --- /dev/null +++ b/docs/superpowers/plans/2026-06-05-s1-three-tier-collection-grain.md @@ -0,0 +1,796 @@ +# S1 — Three-tier collection grain (migration 029 + threading + redirect) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Insert a *collection* grain beneath today's `project` so every entry-scoped row keys on `(collection_id, slug)` instead of `(project_id, slug)`, with one invisible default collection per project — the deployment runs exactly as before, now with a real collection layer and one extra `/c/<collection>/` URL segment. + +**Architecture:** A new `collections` table sits beneath `projects` (which keeps `content_repo`, `name`, `tagline`, `theme`, `visibility`). Migration 029 creates it, moves the per-corpus fields (`type`, `initial_state`) down from `projects`, seeds one default collection (`id='default'`) per project, re-keys the 13 entry-corpus tables `(project_id,slug)→(collection_id,slug)` via the migration-028 rebuild pattern, and generalises `project_members → memberships(scope_type ∈ {project,collection}, …)`. The backend threads `collection_id` through the writers/readers of those 13 tables (project-grain authz is recovered by joining `collections`); the frontend gains a `/c/:collectionId/` route layer and redirects that 308 the shipped `/p/<project>/e/<slug>` URLs to `/p/<project>/c/default/e/<slug>`. Serving stays **project-scoped** in S1 (collection = default); collection-aware serving is S2. + +**Tech Stack:** FastAPI + SQLite (raw SQL migrations run by `backend/app/db.py:run_migrations`, glob-ordered, `-- migrate:no-foreign-keys` marker toggles FK enforcement + runs `foreign_key_check`); pytest "vertical" tests (no Gherkin runner exists — `@S1` scenarios are realised as plain pytest); React Router SPA (`frontend/src/App.jsx`), nginx proxies `/rfc/` + `/proposals/` to the backend for server-side 308s. + +**Binding spec:** `docs/design/2026-06-05-three-tier-projects-collections.md` — Part A (model), Part E / §A.6 (migration strategy), Part C `@S1` scenarios C3.7 + C3.8. + +--- + +## Decisions locked before coding (read first) + +1. **Default collection id = the literal `'default'`** (not the project id). Reason: on a *fresh* deploy migrations run with `project_id='default'` and `restamp` renames it to the configured id (e.g. `ohm`) afterward; on an *already-deployed* instance `project_id` is already `ohm` when 029 runs. A stable literal keeps the collection id **identical across both deploy histories**, matches the spec's `/c/default/` URLs, and lets the existing `restamp` keep working untouched (it renames only the *project* grain — `collections.project_id` and the denormalised `project_id` tags — never `collections.id` or the entry `collection_id`). The re-key maps each entry to its project's default collection via a JOIN, so it is correct regardless of the `project_id` value at migration time. Multi-project deployments at migration time (non-standard pre-S5) get a unique id per project via a `CASE` so the seed never collides. + +2. **Re-key scope = exactly the 13 tables migration 028 rebuilt** (`cached_rfcs`, `rfc_invitations`, `cached_branches`, `branch_visibility`, `branch_contribute_grants`, `stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`, `rfc_collaborators`, `contribution_requests`, `proposed_use_cases`). The other tables 026 tagged with `project_id` (`threads`, `changes`, `notifications`, `actions`, `pr_resolution_branches`, `cached_prs`) keep `project_id` — they carry a project-grain tag, stay consistent for N=1, and renaming them is **out of S1 scope** (deferred). This matches the goal's "re-key entry-scoped tables via the 028 rebuild pattern". + +3. **Serving stays project-scoped in S1.** The `/c/:collectionId/` segment is introduced in routing + redirects; the frontend data layer keeps calling `/api/projects/{project_id}/rfcs/...` (the default collection). Collection-aware serving + the registry `.collection.yaml` reader land in S2. + +4. **No Gherkin runner.** `@S1` acceptance is realised as pytest vertical tests + a frontend route test. The whole existing backend suite is the "N=1 unchanged" regression net — it must go green again after the rename. + +--- + +## File structure + +**Created:** +- `backend/migrations/029_collections.sql` — the migration (collections table, field move-down, default-collection seed, 13-table re-key, `project_members → memberships`). +- `backend/app/collections.py` — collection resolution helpers (`default_collection_id`, `collection_type`, `collection_initial_state`, `collections_of_project`). +- `backend/tests/test_migration_029_collections.py` — migration shape + data-preservation + FK tests (template: `test_migration_028_project_scoped_keys.py`). +- `backend/tests/test_s1_collection_grain_vertical.py` — `@S1` acceptance (C3.7 redirect to sole collection; default-collection redirect; N=1 serving unchanged). + +**Modified (backend):** +- `backend/app/projects.py` — `restamp_default_project` bootstrap check (`cached_rfcs.project_id` → a still-valid column); move `project_initial_state` to read the collection; add re-export shim if needed. +- `backend/app/auth.py` — `project_of_rfc` joins `collections`; the 13-table reads/writes that touch `project_id` switch to `collection_id`. +- `backend/app/cache.py` — `_upsert_cached_rfc(..., collection_id)` + the `cached_rfcs`/`cached_branches` writers + the `WHERE project_id` reconciler reads. +- `backend/app/api.py`, `api_prs.py`, `api_branches.py`, `api_notifications.py`, `api_contributions.py`, `api_invitations.py`, `api_graduation.py`, `funder.py` — every SQL touching the 13 tables' `project_id` column → `collection_id`; recover project via `collections` join where authz needs it. +- `backend/app/api_deployment.py` — `/rfc/{slug}` family 308 targets gain `/c/default/`; `get_deployment`/`get_project` read `type`/`initial_state` from the default collection. + +**Modified (frontend):** +- `frontend/src/components/entryPaths.js` (or wherever path builders live) — insert `/c/:collectionId/`. +- `frontend/src/App.jsx` — add `/c/:collectionId/*` route layer; redirect `/p/:projectId/` → sole/default collection (C3.7); redirect legacy `/p/:projectId/e|proposals/...` → `/c/default/...`. +- `frontend/src/ProjectLayout.jsx` (+ `RFCView.jsx`, `Catalog.jsx` as needed) — read `:collectionId` param; pass through (data stays project-scoped). + +**Modified (release):** +- `VERSION`, `frontend/package.json#version`, `CHANGELOG.md` — minor bump with breaking-URL upgrade-steps block (§20.2 / §20.4). + +--- + +## Phase 1 — Migration 029 (the collection grain) + +### Task 1: Write the migration-029 shape test (red) + +**Files:** +- Test: `backend/tests/test_migration_029_collections.py` + +- [ ] **Step 1: Write the failing test** + +```python +"""Migration 029 — collections grain beneath projects. Template: test_migration_028.""" +import os +import sqlite3 +import tempfile + +import pytest + +from app import db + + +class _Cfg: + def __init__(self, path): + self.database_path = path + self.default_project_id = "default" + + +def _fresh_db(): + d = tempfile.mkdtemp() + path = os.path.join(d, "test.db") + db._CONN = None + db.run_migrations(_Cfg(path)) + return db.conn() + + +def test_collections_table_exists_with_default_per_project(): + conn = _fresh_db() + cols = {r["name"] for r in conn.execute("PRAGMA table_info(collections)")} + assert {"id", "project_id", "type", "subfolder", + "initial_state", "visibility", "name", "registry_sha"} <= cols + # one default collection seeded for the bootstrap 'default' project + row = conn.execute( + "SELECT id, project_id, subfolder FROM collections WHERE project_id='default'" + ).fetchone() + assert row is not None + assert row["id"] == "default" + assert row["subfolder"] == "" # repo root + + +def test_per_corpus_fields_moved_off_projects(): + conn = _fresh_db() + proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")} + assert "type" not in proj_cols + assert "initial_state" not in proj_cols + # projects keeps the grouping-tier fields + assert {"id", "name", "content_repo", "visibility"} <= proj_cols + + +def test_entry_tables_rekeyed_to_collection_id(): + conn = _fresh_db() + for t in ("cached_rfcs", "cached_branches", "stars", "watches", + "rfc_collaborators", "contribution_requests", "proposed_use_cases", + "branch_visibility", "branch_contribute_grants", "pr_seen", + "branch_chat_seen", "funder_consents", "rfc_invitations"): + cols = {r["name"] for r in conn.execute(f"PRAGMA table_info({t})")} + assert "collection_id" in cols, f"{t} missing collection_id" + assert "project_id" not in cols, f"{t} still has project_id" + + +def test_cached_rfcs_pk_is_collection_slug(): + conn = _fresh_db() + # same slug coexists across two collections + conn.execute("INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) " + "VALUES ('c2','default','document','specs','active','public','Specs')") + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','default')") + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','B','active','c2')") + n = conn.execute("SELECT COUNT(*) c FROM cached_rfcs WHERE slug='intro'").fetchone()["c"] + assert n == 2 + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','dup','active','default')") + + +def test_collaborator_fk_is_composite_on_collection(): + conn = _fresh_db() + conn.execute("INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) " + "VALUES ('c2','default','document','specs','active','public','Specs')") + conn.execute("INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES ('intro','A','active','c2')") + conn.execute("INSERT INTO users (id, email, role, permission_state) VALUES (1,'a@b.c','contributor','granted')") + conn.execute("PRAGMA foreign_keys=ON") + conn.execute("INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) " + "VALUES ('intro',1,'contributor','c2')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, collection_id) " + "VALUES ('intro',1,'contributor','default')") # no such (collection,slug) + + +def test_memberships_table_replaces_project_members(): + conn = _fresh_db() + cols = {r["name"] for r in conn.execute("PRAGMA table_info(memberships)")} + assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols + # M2 rows would migrate to scope_type='collection'; role enum collapsed to owner/contributor + # (no project_members rows exist in a fresh DB, so just assert the table + check constraint) + conn.execute("INSERT INTO users (id, email, role, permission_state) VALUES (9,'x@y.z','contributor','granted')") + conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('project','default',9,'owner')") + with pytest.raises(sqlite3.IntegrityError): + conn.execute("INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('bogus','default',9,'owner')") +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `cd backend && python -m pytest tests/test_migration_029_collections.py -q` +Expected: FAIL (no `collections` table / `029_collections.sql` does not exist). + +- [ ] **Step 3: Commit the red test** + +```bash +git add backend/tests/test_migration_029_collections.py +git commit -m "§22 S1: failing migration-029 shape tests (collections grain)" +``` + +### Task 2: Write migration 029 (green the shape test) + +**Files:** +- Create: `backend/migrations/029_collections.sql` + +- [ ] **Step 1: Write the migration.** Mirror `028_project_scoped_keys.sql` exactly for the 13 rebuilds, with `project_id` renamed to `collection_id` in each `__new` table, each child FK re-pointed to `cached_rfcs(collection_id, slug)`, and each index/UNIQUE swapping `project_id`→`collection_id`. Use the explicit-column `INSERT ... SELECT` form (not `SELECT *`) so the re-key can map values. Header marker `-- migrate:no-foreign-keys`. Concrete top of file: + +```sql +-- migrate:no-foreign-keys +-- +-- §22 three-tier refactor — S1. Insert a *collection* grain beneath project. +-- (1) collections table; (2) move per-corpus fields (type, initial_state) down +-- from projects; (3) one default collection per project (id='default', +-- subfolder = repo root); (4) re-key the 13 entry-corpus tables +-- (project_id,slug) -> (collection_id,slug) via the 028 rebuild pattern, mapping +-- each row to its project's default collection by JOIN; (5) project_members -> +-- memberships(scope_type ∈ {project,collection}, …), role enum collapsed to +-- {owner, contributor}. FK enforcement is OFF for the file (marker above); +-- foreign_key_check runs after. See docs/design/2026-06-05-three-tier-…md §A.6. + +-- ── collections: the new typed-corpus grain beneath projects ─────────────── +CREATE TABLE collections ( + id TEXT NOT NULL, + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + type TEXT NOT NULL DEFAULT 'document' + CHECK (type IN ('document', 'specification', 'bdd')), + subfolder TEXT NOT NULL DEFAULT '', + initial_state TEXT NOT NULL DEFAULT 'super-draft' + CHECK (initial_state IN ('super-draft', 'active')), + visibility TEXT NOT NULL DEFAULT 'gated' + CHECK (visibility IN ('gated', 'public', 'unlisted')), + name TEXT, + registry_sha TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (id) +); +CREATE INDEX idx_collections_project ON collections(project_id); + +-- One default collection per project. id='default' for the standard +-- single-project deployment (stable across deploy histories); the project_id is +-- used as a unique fallback id only if a non-standard multi-project deployment +-- migrates (pre-S5; avoids a PK collision). subfolder='' = repo root. +INSERT INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) +SELECT + CASE WHEN (SELECT COUNT(*) FROM projects) <= 1 THEN 'default' ELSE p.id END, + p.id, p.type, '', p.initial_state, p.visibility, p.name +FROM projects p; + +-- ── move per-corpus fields off projects (rebuild to DROP type/initial_state) ─ +CREATE TABLE projects__new ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + content_repo TEXT, + visibility TEXT NOT NULL DEFAULT 'gated' + CHECK (visibility IN ('gated', 'public', 'unlisted')), + config_json TEXT, + registry_sha TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); +INSERT INTO projects__new (id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at) +SELECT id, name, content_repo, visibility, config_json, registry_sha, created_at, updated_at FROM projects; +DROP TABLE projects; +ALTER TABLE projects__new RENAME TO projects; + +-- ── cached_rfcs: PRIMARY KEY (project_id, slug) -> (collection_id, slug) ──── +-- collection_id mapped from the row's old project's default collection. +CREATE TABLE cached_rfcs__new ( + slug TEXT NOT NULL, + title TEXT NOT NULL, + state TEXT NOT NULL CHECK (state IN ('super-draft', 'active', 'withdrawn', 'retired')), + rfc_id TEXT, + repo TEXT, + proposed_by TEXT, + proposed_at TEXT, + graduated_at TEXT, + graduated_by TEXT, + owners_json TEXT NOT NULL DEFAULT '[]', + arbiters_json TEXT NOT NULL DEFAULT '[]', + tags_json TEXT NOT NULL DEFAULT '[]', + body TEXT, + body_sha TEXT, + last_main_commit_at TEXT, + last_entry_commit_at TEXT, + updated_at TEXT NOT NULL DEFAULT (datetime('now')), + models_json TEXT, + funder_login TEXT, + proposed_use_case TEXT, + collection_id TEXT NOT NULL DEFAULT 'default' REFERENCES collections(id), + unreviewed INTEGER NOT NULL DEFAULT 0, + reviewed_at TEXT, + reviewed_by TEXT, + PRIMARY KEY (collection_id, slug) +); +INSERT INTO cached_rfcs__new + (slug, title, state, rfc_id, repo, proposed_by, proposed_at, graduated_at, + graduated_by, owners_json, arbiters_json, tags_json, body, body_sha, + last_main_commit_at, last_entry_commit_at, updated_at, models_json, + funder_login, proposed_use_case, collection_id, unreviewed, reviewed_at, reviewed_by) +SELECT + r.slug, r.title, r.state, r.rfc_id, r.repo, r.proposed_by, r.proposed_at, r.graduated_at, + r.graduated_by, r.owners_json, r.arbiters_json, r.tags_json, r.body, r.body_sha, + r.last_main_commit_at, r.last_entry_commit_at, r.updated_at, r.models_json, + r.funder_login, r.proposed_use_case, + (SELECT c.id FROM collections c WHERE c.project_id = r.project_id LIMIT 1), + r.unreviewed, r.reviewed_at, r.reviewed_by +FROM cached_rfcs r; +DROP TABLE cached_rfcs; +ALTER TABLE cached_rfcs__new RENAME TO cached_rfcs; +CREATE INDEX idx_cached_rfcs_state ON cached_rfcs (state); +CREATE INDEX idx_cached_rfcs_last_active ON cached_rfcs ( + COALESCE(last_main_commit_at, last_entry_commit_at) DESC +); +CREATE INDEX idx_cached_rfcs_collection ON cached_rfcs(collection_id); +``` + +Then **for each of the remaining 12 tables** copy its `028` block verbatim and apply the same three transforms: (a) rename the `project_id` column to `collection_id` (keep `DEFAULT 'default'`); (b) in the `INSERT ... SELECT`, replace the `project_id` source value with `(SELECT c.id FROM collections c WHERE c.project_id = <old>.project_id LIMIT 1)` and list columns explicitly; (c) rename `project_id` → `collection_id` in every `UNIQUE (...)`, `FOREIGN KEY (...) REFERENCES cached_rfcs(...)`, and `CREATE [UNIQUE] INDEX`. The 12: `rfc_invitations`, `cached_branches`, `branch_visibility`, `branch_contribute_grants`, `stars`, `watches`, `pr_seen`, `branch_chat_seen`, `funder_consents`, `rfc_collaborators`, `contribution_requests`, `proposed_use_cases`. (FK targets `cached_rfcs(project_id, slug)` become `cached_rfcs(collection_id, slug)`.) + +Finally the membership generalisation: + +```sql +-- ── project_members -> memberships(scope_type, scope_id, …); roles collapsed ─ +CREATE TABLE memberships ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + scope_type TEXT NOT NULL CHECK (scope_type IN ('project', 'collection')), + scope_id TEXT NOT NULL, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('owner', 'contributor')), + granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + UNIQUE (scope_type, scope_id, user_id) +); +CREATE INDEX idx_memberships_user ON memberships(user_id); +CREATE INDEX idx_memberships_scope ON memberships(scope_type, scope_id); + +-- M2 project_members rows attached at what is now the *collection*; collapse +-- the role enum (project_admin -> owner, project_contributor -> contributor, +-- project_viewer -> dropped this pass, §B.3) and migrate to the default +-- collection of each project. +INSERT INTO memberships (scope_type, scope_id, user_id, role, granted_by, granted_at) +SELECT 'collection', + (SELECT c.id FROM collections c WHERE c.project_id = pm.project_id LIMIT 1), + pm.user_id, + CASE pm.role WHEN 'project_admin' THEN 'owner' + WHEN 'project_contributor' THEN 'contributor' + ELSE 'contributor' END, + pm.granted_by, pm.granted_at +FROM project_members pm +WHERE pm.role IN ('project_admin', 'project_contributor'); +DROP TABLE project_members; +``` + +- [ ] **Step 2: Run the shape test** + +Run: `cd backend && python -m pytest tests/test_migration_029_collections.py -q` +Expected: PASS (all shape/PK/FK/membership assertions green). + +- [ ] **Step 3: Commit** + +```bash +git add backend/migrations/029_collections.sql +git commit -m "§22 S1: migration 029 — collections grain, field move-down, 13-table re-key, memberships" +``` + +--- + +## Phase 2 — Backend threading (make the existing suite green again) + +> After Task 2 the column rename breaks every reader/writer of the 13 tables. This phase fixes them. **Driver:** the full backend suite is the regression net — run it, read each failure, fix the named module, repeat until green. The agent exploration produced the exact blast-radius map used below. + +### Task 3: collections helper module + +**Files:** +- Create: `backend/app/collections.py` + +- [ ] **Step 1: Write the helper** + +```python +"""§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 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 LIMIT 1", + (project_id,), + ).fetchone() + return row["id"] if row else DEFAULT_COLLECTION_ID + + +def project_of_collection(collection_id: str) -> str | None: + 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 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: + row = db.conn().execute( + "SELECT type FROM collections WHERE id = ?", (collection_id,) + ).fetchone() + return row["type"] if row and row["type"] else "document" +``` + +- [ ] **Step 2: Commit** + +```bash +git add backend/app/collections.py +git commit -m "§22 S1: collections resolution helpers" +``` + +### Task 4: Fix `projects.py` (restamp + initial_state) + +**Files:** +- Modify: `backend/app/projects.py:46-48` (restamp bootstrap check), `:112-121` (`project_initial_state`) + +- [ ] **Step 1: Fix the restamp bootstrap check.** `restamp_default_project` reads `cached_rfcs.project_id` (now renamed) at line 47 — switch the existence probe to a still-`project_id`-bearing table so the PRAGMA-driven rename loop is unaffected (it already discovers `project_id` columns dynamically, which now correctly excludes the 13 collection-keyed tables and includes `collections.project_id`): + +```python + has_rows = conn.execute( + "SELECT 1 FROM collections WHERE project_id = ? LIMIT 1", (DEFAULT_PROJECT_ID,) + ).fetchone() +``` + +- [ ] **Step 2: Re-home `project_initial_state`.** Keep the signature for callers, but resolve through the project's default collection: + +```python +def project_initial_state(project_id: str) -> str: + """§22.4b landing state for new entries in a project's default collection.""" + from . import collections as collections_mod + return collections_mod.collection_initial_state( + collections_mod.default_collection_id(project_id) + ) +``` + +- [ ] **Step 3: Run the restamp + projects tests** + +Run: `cd backend && python -m pytest tests/test_restamp_default_project.py tests/test_initial_state_landing.py -q` +Expected: PASS. + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/projects.py +git commit -m "§22 S1: thread projects.py restamp + initial_state through collections" +``` + +### Task 5: Fix `auth.py` (`project_of_rfc` join) + +**Files:** +- Modify: `backend/app/auth.py:352-361` + +- [ ] **Step 1: Join collections to recover the project from a slug.** + +```python +def project_of_rfc(rfc_slug: str) -> str: + """The project an RFC belongs to, via its collection + (cached_rfcs.collection_id -> collections.project_id). Falls back to the + default project when the slug isn't cached.""" + row = db.conn().execute( + "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 + return row["project_id"] or DEFAULT_PROJECT_ID +``` + +- [ ] **Step 2: Run the authz suite** + +Run: `cd backend && python -m pytest tests/test_multi_project_authz_vertical.py tests/test_anon_offlimits_vertical.py -q` +Expected: PASS. + +- [ ] **Step 3: Commit** + +```bash +git add backend/app/auth.py +git commit -m "§22 S1: auth.project_of_rfc recovers project via collection join" +``` + +### Task 6: Fix `cache.py` writers/readers + +**Files:** +- Modify: `backend/app/cache.py` — `_refresh_project_corpus` (resolve collection), `_upsert_cached_rfc` signature + SQL (`project_id`→`collection_id`), the `WHERE project_id` reconciler read (`:88`), the `cached_branches` writers (`:213/:388/:410`). + +- [ ] **Step 1: Resolve the collection in the corpus refresh.** In `_refresh_project_corpus`, compute the project's default collection once and pass it down; switch the reconciler `SELECT slug ... WHERE project_id` to `WHERE collection_id`: + +```python +async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None: + from . import collections as collections_mod + collection_id = collections_mod.default_collection_id(project_id) + ... + _upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id) + ... + existing = { + row["slug"] + for row in db.conn().execute( + "SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,) + ) + } +``` + +- [ ] **Step 2: Rename in `_upsert_cached_rfc`.** Change the param `project_id: str = "default"` → `collection_id: str = "default"`; in the `INSERT`, replace the `project_id` column with `collection_id`, the `ON CONFLICT(project_id, slug)` with `ON CONFLICT(collection_id, slug)`, and the bound value `project_id` → `collection_id`. + +- [ ] **Step 3: Fix the `cached_branches` writers.** At `:213/:388/:410` the `ON CONFLICT(project_id, rfc_slug, branch_name)` clauses → `ON CONFLICT(collection_id, rfc_slug, branch_name)`; where a meta-repo branch row is written without an explicit grain it now relies on the `collection_id DEFAULT 'default'` column default (unchanged behaviour for N=1). Bind `collection_id` explicitly where the per-project loop has it. + +- [ ] **Step 4: Run the cache tests** + +Run: `cd backend && python -m pytest tests/test_cache_bootstrap.py tests/test_cache_review_fields.py tests/test_branch_path_routing.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/cache.py +git commit -m "§22 S1: thread cache.py corpus/branch writers through collection_id" +``` + +### Task 7: Fix the `api_*` writers/readers + `funder.py` + +**Files (each: swap the 13-table `project_id` column references to `collection_id`; recover project for authz via `auth.project_of_rfc`/`collections` join):** +- `backend/app/api.py:747` (stars read), `:774/:806/:969` (`cached_rfcs` composite lookups → `collection_id`), `:785-788/:1046` (`proposed_use_cases`). +- `backend/app/api_prs.py:156` (`branch_visibility`), `:192` (`proposed_use_cases`), `:401` (`pr_seen`). Note `:670/:789` read `row["project_id"]` from a `cached_rfcs`/`rfc` row — change those SELECTs to also yield the project via the collection join, then keep the existing `auth.require_project_readable(viewer, project_id)` call unchanged. +- `backend/app/api_branches.py:745` (`branch_visibility`), `:899` (`branch_chat_seen`). +- `backend/app/api_notifications.py:216` (read `cached_rfcs` → now `collection_id`; recover project via join for the visibility gate), `:225` (`watches`). +- `backend/app/api_contributions.py:65/:111` (read `cached_rfcs`; recover project via join), `api_invitations.py:383`, `api_graduation.py` (any `cached_rfcs`/13-table `project_id`). +- `backend/app/funder.py:223` (`funder_consents` `ON CONFLICT(project_id,…)` → `collection_id`). + +- [ ] **Step 1: Mechanical pass.** For each file above, replace `project_id` **only where it names a column on one of the 13 re-keyed tables** (PK lookups, `ON CONFLICT`, `WHERE`, `INSERT` column lists, `SELECT` projections from those tables) with `collection_id`. Where the code needs the *project* (for `auth.*_project*` calls), recover it with `auth.project_of_rfc(slug)` or a `collections` join — do **not** rename the `project_id` argument flowing into the authz helpers (those stay project-grain in S1). Leave `threads`, `changes`, `notifications`, `actions`, `pr_resolution_branches`, `cached_prs` `project_id` columns untouched. + +- [ ] **Step 2: Grep guard.** Confirm no stray reference to a dropped column remains: + +Run: `cd backend && grep -rEn "cached_rfcs[^;]*project_id|project_id, slug|project_id, rfc_slug|ON CONFLICT\(project_id" app/ | grep -v "collections\|threads\|changes\|notifications\|actions\|pr_resolution\|cached_prs"` +Expected: no output (every 13-table `project_id` is now `collection_id`). + +- [ ] **Step 3: Run the full backend suite** + +Run: `cd backend && python -m pytest -q` +Expected: PASS (this is the **N=1-unchanged** gate). Fix any remaining failures by reading the traceback and applying the same rename/join rule. + +- [ ] **Step 4: Commit** + +```bash +git add backend/app/api.py backend/app/api_prs.py backend/app/api_branches.py backend/app/api_notifications.py backend/app/api_contributions.py backend/app/api_invitations.py backend/app/api_graduation.py backend/app/funder.py +git commit -m "§22 S1: thread api_* + funder writers/readers through collection_id" +``` + +--- + +## Phase 3 — API surface reads per-corpus fields from the collection + +### Task 8: `api_deployment.py` reads type/initial_state from the default collection + +**Files:** +- Modify: `backend/app/api_deployment.py:36-44` (`get_deployment` projects list `type`), `:62-82` (`get_project` `type`/`initial_state`) + +- [ ] **Step 1: Write a failing test** in `backend/tests/test_api_deployment.py` (extend it) asserting `GET /api/projects/{default}` still returns the correct `type`/`initial_state` after the move-down (values come from the default collection): + +```python +def test_get_project_type_initial_state_from_default_collection(app_with_fake_gitea): + # ... existing fixture sets up the default project/collection ... + r = client.get(f"/api/projects/{default_id}") + assert r.status_code == 200 + body = r.json() + assert body["type"] in ("document", "specification", "bdd") + assert body["initial_state"] in ("super-draft", "active") +``` + +- [ ] **Step 2: Run to verify it fails** (the SELECT still reads `projects.type`, which 029 dropped → `OperationalError`). + +Run: `cd backend && python -m pytest tests/test_api_deployment.py -q` +Expected: FAIL. + +- [ ] **Step 3: Read the fields from the default collection.** In `get_deployment`, replace the `SELECT id, name, type, visibility FROM projects` with a join to the project's default collection for `type` (or a per-row `collections_mod.collection_type(default_collection_id(id))`). In `get_project`, drop `type, initial_state` from the `projects` SELECT and resolve them via `collections_mod.collection_type(...)` / `collection_initial_state(...)`: + +```python +from . import collections as collections_mod +... + cid = collections_mod.default_collection_id(row["id"]) + return { + ... + "type": collections_mod.collection_type(cid), + "initial_state": collections_mod.collection_initial_state(cid), + ... + } +``` + +- [ ] **Step 4: Run to verify it passes** + +Run: `cd backend && python -m pytest tests/test_api_deployment.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api_deployment.py backend/tests/test_api_deployment.py +git commit -m "§22 S1: deployment/project API reads type+initial_state from default collection" +``` + +--- + +## Phase 4 — Redirects + frontend collection segment + +### Task 9: Backend `/rfc/` 308s target `/c/default/` + +**Files:** +- Modify: `backend/app/api_deployment.py:89-104` + +- [ ] **Step 1: Add a failing test** to `test_api_deployment.py`: + +```python +def test_legacy_rfc_url_redirects_through_collection(app_with_fake_gitea): + r = client.get("/rfc/intro", follow_redirects=False) + assert r.status_code == 308 + assert r.headers["location"] == f"/p/{default_id}/c/default/e/intro" +``` + +- [ ] **Step 2: Verify it fails** (current target lacks `/c/default/`). + +- [ ] **Step 3: Update the three redirect handlers** to resolve the default collection and insert the `/c/<cid>/` segment: + +```python + @router.get("/rfc/{slug}") + async def redirect_old_rfc(slug: str) -> RedirectResponse: + default_id = projects_mod.resolved_default_id(config) + cid = collections_mod.default_collection_id(default_id) + return RedirectResponse(url=f"/p/{default_id}/c/{cid}/e/{slug}", status_code=308) + # …same /c/{cid}/ insertion for /rfc/{slug}/pr/{pr} and /proposals/{pr} +``` + +(`/proposals/{pr}` → `/p/{default_id}/c/{cid}/proposals/{pr}`.) + +- [ ] **Step 4: Verify it passes.** + +Run: `cd backend && python -m pytest tests/test_api_deployment.py -q` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add backend/app/api_deployment.py backend/tests/test_api_deployment.py +git commit -m "§22 S1: legacy /rfc + /proposals 308s route through /c/<default>/" +``` + +### Task 10: Frontend path builders gain `/c/:collectionId/` + +**Files:** +- Modify: `frontend/src/components/entryPaths.js` (path builders — confirm exact path with `grep -rl "p/\${" frontend/src`) + +- [ ] **Step 1: Thread a collection id through the builders.** Add a `collectionId` argument (defaulting to `'default'`) and emit the `/c/<collectionId>/` segment: + +```js +export const collectionHome = (projectId, collectionId) => `/p/${projectId}/c/${collectionId}/` +export const entryPath = (projectId, collectionId, slug) => `/p/${projectId}/c/${collectionId}/e/${slug}` +export const entryPrPath = (projectId, collectionId, slug, prNumber) => `/p/${projectId}/c/${collectionId}/e/${slug}/pr/${prNumber}` +export const proposalPath = (projectId, collectionId, prNumber) => `/p/${projectId}/c/${collectionId}/proposals/${prNumber}` +export const projectHome = (projectId) => `/p/${projectId}/` +``` + +Update every caller (grep `entryPath(`, `entryPrPath(`, `proposalPath(`, `collectionHome(`) to pass the current collection id (from the route param / `useCollectionId()` — default `'default'`). + +- [ ] **Step 2: Build the frontend** + +Run: `cd frontend && npm run build` +Expected: build succeeds (no undefined-symbol errors). + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src +git commit -m "§22 S1: frontend path builders carry the /c/<collection>/ segment" +``` + +### Task 11: Frontend route layer + redirects (C3.7, C3.8, legacy) + +**Files:** +- Modify: `frontend/src/App.jsx:354-373`, `frontend/src/ProjectLayout.jsx` + +- [ ] **Step 1: Nest the corpus routes under `/c/:collectionId/`** and add redirects. Inside the `ProjectLayout` nested `<Routes>`: + +```jsx +<Routes> + {/* project landing: redirect to the sole/default collection (C3.7) */} + <Route path="" element={<CollectionRedirect />} /> + {/* legacy v0.35.0 corpus URLs without /c/ → default collection */} + <Route path="e/:slug" element={<Navigate to="c/default/e/:slug" replace />} /> + <Route path="e/:slug/pr/:prNumber" element={<LegacyEntryPrRedirect />} /> + <Route path="proposals/:prNumber" element={<LegacyProposalRedirect />} /> + {/* collection-scoped corpus (serving stays project-scoped in S1) */} + <Route path="c/:collectionId" element={<Welcome viewer={viewer} />} /> + <Route path="c/:collectionId/e/:slug" element={<RFCView viewer={viewer} />} /> + <Route path="c/:collectionId/e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} /> + <Route path="c/:collectionId/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} /> +</Routes> +``` + +`CollectionRedirect` reads the project's collections (from `ProjectContext`, populated by `GET /api/projects/:id`) and `<Navigate>`s to the sole visible collection's `/c/<id>/`; with one collection that is `/c/default/` (C3.7). `LegacyEntryPrRedirect`/`LegacyProposalRedirect` use `useParams()` to rebuild the target with `c/default/`. React-Router literal `:slug` in `to=` does not interpolate — implement these as small components using `useParams()` + `<Navigate>`. + +- [ ] **Step 2: Confirm `/` → sole project (C3.8) already holds.** `DeploymentLanding` (App.jsx:348) already redirects to the single visible project. Add/confirm a test (Task 12) rather than re-implementing. + +- [ ] **Step 3: Build** + +Run: `cd frontend && npm run build` +Expected: succeeds. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src +git commit -m "§22 S1: /c/<collection>/ route layer + C3.7 + legacy-URL redirects" +``` + +--- + +## Phase 5 — `@S1` acceptance + full verification + +### Task 12: `@S1` vertical acceptance test (C3.7 + C3.8 + N=1 serving) + +**Files:** +- Create: `backend/tests/test_s1_collection_grain_vertical.py` + +- [ ] **Step 1: Write the acceptance test.** Tag scenarios in docstrings as `@S1` for traceability (no Gherkin runner). Cover: (a) default-collection redirect `/rfc/<slug>` → `/p/<default>/c/default/e/<slug>` (already in Task 9 — re-assert here as the S1 gate); (b) an entry proposed/served at N=1 still resolves under the default collection via `/api/projects/<default>/rfcs/<slug>`; (c) the deployment `/api/deployment` still reports one project with `default_project_id`. (C3.7/C3.8 client redirects are asserted in the frontend build/route smoke; the data-layer N=1 invariants are asserted here.) + +```python +"""@S1 acceptance — the collection grain exists and N=1 is unchanged. +Scenarios: C3.7 (single-collection project skips the directory) and C3.8 +(single-project deployment skips the directory) are the redirect contract; +this module asserts the backend N=1 invariants behind them.""" +# reuse the propose/serve fixtures from test_project_scoped_serving.py +def test_s1_entry_served_under_default_collection(app_with_fake_gitea): + # propose + mirror an entry, then fetch it project-scoped (collection=default) + ... + r = client.get(f"/api/projects/{default_id}/rfcs/intro") + assert r.status_code == 200 + # the row is keyed by collection_id under the hood + cid = db.conn().execute("SELECT collection_id FROM cached_rfcs WHERE slug='intro'").fetchone()["collection_id"] + assert cid == "default" + +def test_s1_legacy_redirect_inserts_collection_segment(app_with_fake_gitea): + r = client.get("/rfc/intro", follow_redirects=False) + assert r.status_code == 308 + assert "/c/default/" in r.headers["location"] +``` + +- [ ] **Step 2: Run it** + +Run: `cd backend && python -m pytest tests/test_s1_collection_grain_vertical.py -q` +Expected: PASS. + +- [ ] **Step 3: Full backend suite + frontend build (the N=1-unchanged gate)** + +Run: `cd backend && python -m pytest -q && cd ../frontend && npm run build` +Expected: all backend tests PASS; frontend builds. + +- [ ] **Step 4: Commit** + +```bash +git add backend/tests/test_s1_collection_grain_vertical.py +git commit -m "§22 S1: @S1 acceptance — collection grain + N=1 serving unchanged" +``` + +### Task 13: e2e smoke (optional, if Docker stack available) + +- [ ] **Step 1:** If the Tier-1 Docker stack is runnable, `make e2e` to confirm sign-in + a corpus page render through the new `/c/default/` routes. If the stack isn't available in-session, note it skipped and rely on Tasks 7/11/12 gates. + +--- + +## Phase 6 — Release + finalize + +### Task 14: Version bump + changelog (breaking, with upgrade steps) + +**Files:** +- Modify: `VERSION`, `frontend/package.json` (`version`), `CHANGELOG.md` + +- [ ] **Step 1: Bump** `VERSION` and `frontend/package.json#version` to the next pre-1.0 minor (current `0.39.0` → `0.40.0`). They must match (a divergence is a §20 spec bug). + +- [ ] **Step 2: Add the CHANGELOG entry** with a breaking-URL **upgrade steps** block (§20.2 / §20.4 / §A.6): migration 029 adds the collection grain; `/p/<project>/e/<slug>` now lives at `/p/<project>/c/default/e/<slug>` (308 for old links); operators need no action beyond deploying (the migration + redirects are automatic; the default collection is seeded). Note the deferred items (denormalised `project_id` tags unchanged; collection-aware serving = S2). + +- [ ] **Step 3: Commit** + +```bash +git add VERSION frontend/package.json CHANGELOG.md +git commit -m "§22 S1: release v0.40.0 — three-tier collection grain (breaking URL + migration 029)" +``` + +### Task 15: Branch, PR, merge + +- [ ] **Step 1:** This work rides a feature branch off `main` (e.g. `feat/s1-collection-grain`). Push to `origin` (git.wiggleverse.org). +- [ ] **Step 2:** Open a PR citing the design doc + `@S1`; in autonomous posture, self-review and merge once the suite is green. +- [ ] **Step 3:** Update repo memory with the new resume pointer (S1 shipped @ v0.40.0; next = S2). + +--- + +## Self-review (writing-plans checklist) + +- **Spec coverage:** §A.6 steps 1–5 → Tasks 2 (collections table + default + re-key + memberships), 8 (field move-down read path), 9 (308 step 5). Part B membership generalisation → Task 2 (`memberships`) — note S1 only *migrates* the table; the four-layer resolver is S3 (out of scope, correctly deferred per Part E). `@S1` C3.7/C3.8 → Tasks 11 (frontend redirects) + 12 (backend invariants). "N=1 unchanged" → Task 7 Step 3 + Task 12 Step 3 full-suite gates. Threading (auth/projects/cache/api_*) → Tasks 4–8. +- **Placeholders:** the per-table rebuild bodies for the 12 non-`cached_rfcs` tables reference the in-repo `028_project_scoped_keys.sql` as the literal template with the three explicit transforms named — this is a concrete instruction, not a TODO (repeating 200+ lines of near-identical SQL verbatim would harm reviewability; the transform rule is exact). +- **Type consistency:** `default_collection_id`, `collection_type`, `collection_initial_state`, `project_of_collection` are defined in Task 3 and used consistently in Tasks 4, 8, 9. Column `collection_id` (not `coll_id`/`collectionId`) used uniformly in SQL; `collectionId` is the JS route param. +- **Risk note:** the denormalised `project_id` columns (`threads`/`changes`/`notifications`/`actions`/`pr_resolution_branches`/`cached_prs`) stay `project_id` and may, after `restamp`, hold the project id (`ohm`) while entry `collection_id` holds `default`. Task 7 Step 3's full-suite run is the guard against any code that wrongly cross-joins the two grains; if one surfaces, recover the project via the `collections` join rather than renaming the tag. +``` diff --git a/frontend/package.json b/frontend/package.json index 3a523eb..1bb033b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,7 +1,7 @@ { "name": "rfc-app-frontend", "private": true, - "version": "0.39.0", + "version": "0.42.0", "type": "module", "scripts": { "dev": "vite", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index b9a0aed..6bdf11b 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,10 +1,10 @@ import { useEffect, useRef, useState } from 'react' -import { Routes, Route, Link, Navigate, useLocation, useNavigate, useSearchParams } from 'react-router-dom' +import { Routes, Route, Link, Navigate, useLocation, useNavigate, useParams, useSearchParams } from 'react-router-dom' import { getMe, subscribeToNotifications } from './api' import { anonymize, EVENTS, identify, track } from './lib/analytics' import { useLastState } from './lib/useLastState' import { brandTitle } from './lib/brand' -import { entryPath, proposalPath } from './lib/entryPaths' +import { entryPath, proposalPath, DEFAULT_COLLECTION } from './lib/entryPaths' import { useDeployment } from './context/DeploymentProvider' import ProjectLayout from './components/ProjectLayout.jsx' import Directory from './components/Directory.jsx' @@ -15,6 +15,7 @@ import RFCView from './components/RFCView.jsx' import PRView from './components/PRView.jsx' import ProposalView from './components/ProposalView.jsx' import ProposeModal from './components/ProposeModal.jsx' +import CollectionDirectory from './components/CollectionDirectory.jsx' import ContributeRequestForm from './components/ContributeRequestForm.jsx' import Landing from './components/Landing.jsx' import Login from './components/Login.jsx' @@ -66,6 +67,10 @@ export default function App() { // right project. Falls back to the deployment default off a project route. const _projMatch = location.pathname.match(/^\/p\/([^/]+)/) const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId + // §22 S2 — the collection the viewer is currently in (from the /c/<cid>/ URL + // segment), so a propose targets that collection. Falls back to the default. + const _colMatch = location.pathname.match(/^\/p\/[^/]+\/c\/([^/]+)/) + const currentCollectionId = (_colMatch && _colMatch[1]) || DEFAULT_COLLECTION // #28 Parts 2–3: the LinkedText create/contribute affordances route via // query params so they need no prop-threading from deep in a comment // list. `?propose=<term>` opens the propose modal pre-filled; @@ -360,10 +365,22 @@ export default function App() { /> <main className="main-pane"> <Routes> - <Route path="" element={<Welcome viewer={viewer} />} /> - <Route path="e/:slug" element={<RFCView viewer={viewer} />} /> - <Route path="e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} /> - <Route path="proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} /> + {/* §22 S2: the project landing is the collection directory — + it lists collections, or (C3.7/C3.8) redirects into the + sole visible collection when there is exactly one. */} + <Route path="" element={<CollectionDirectoryRoute />} /> + {/* Backcompat: the shipped v0.35.0 corpus URLs without a + /c/<collection>/ segment redirect into the default + collection, so old bookmarks keep working. */} + <Route path="e/:slug" element={<LegacyCorpusRedirect kind="entry" />} /> + <Route path="e/:slug/pr/:prNumber" element={<LegacyCorpusRedirect kind="entryPr" />} /> + <Route path="proposals/:prNumber" element={<LegacyCorpusRedirect kind="proposal" />} /> + {/* Collection-scoped corpus. Serving stays project-scoped in + S1 (collection = default); collection-aware serving is S2. */} + <Route path="c/:collectionId" element={<Welcome viewer={viewer} />} /> + <Route path="c/:collectionId/e/:slug" element={<RFCView viewer={viewer} />} /> + <Route path="c/:collectionId/e/:slug/pr/:prNumber" element={<PRView viewer={viewer} />} /> + <Route path="c/:collectionId/proposals/:prNumber" element={<ProposalView viewer={viewer} onChange={() => setCatalogVersion(v => v + 1)} />} /> </Routes> </main> </ProjectLayout> @@ -378,12 +395,13 @@ export default function App() { viewer={viewer} initialTitle={proposeParam || ''} projectId={currentProjectId} + collectionId={currentCollectionId} onClose={() => { setProposeOpen(false); clearParams('propose') }} onSubmitted={({ pr_number }) => { setProposeOpen(false) clearParams('propose') setCatalogVersion(v => v + 1) - navigate(proposalPath(currentProjectId, pr_number)) + navigate(proposalPath(currentProjectId, pr_number, currentCollectionId)) }} /> )} @@ -403,6 +421,28 @@ export default function App() { ) } +// §22 S2 — the project landing at /p/:projectId/ is the collection directory. +// A tiny wrapper reads the route's projectId and hands it to CollectionDirectory +// (which lists collections, or redirects into the sole one — C3.7/C3.8). +function CollectionDirectoryRoute() { + const { projectId } = useParams() + return <CollectionDirectory projectId={projectId} /> +} + +// Backcompat for the shipped v0.35.0 corpus URLs that lacked the +// /c/<collection>/ segment: redirect into the default collection, preserving any +// query string (e.g. ?branch=). +function LegacyCorpusRedirect({ kind }) { + const { projectId, slug, prNumber } = useParams() + const { search } = useLocation() + const base = `/p/${projectId}/c/${DEFAULT_COLLECTION}` + let to = `${base}/` + if (kind === 'entry') to = `${base}/e/${slug}` + else if (kind === 'entryPr') to = `${base}/e/${slug}/pr/${prNumber}` + else if (kind === 'proposal') to = `${base}/proposals/${prNumber}` + return <Navigate to={to + (search || '')} replace /> +} + function DeploymentLanding() { // §22.10 + design decision 2 — N=1 lands in the single visible project so // OHM's "land in the corpus" UX is preserved; the directory appears only diff --git a/frontend/src/api.collections.test.js b/frontend/src/api.collections.test.js new file mode 100644 index 0000000..f446a8c --- /dev/null +++ b/frontend/src/api.collections.test.js @@ -0,0 +1,48 @@ +// §22 S2 — the API client builds collection-scoped URLs when a collection id is +// supplied, and falls back to the project/default-collection paths otherwise. +import { describe, it, expect, vi, afterEach } from 'vitest' +import { listRFCs, getRFC, proposeRFC, listCollections } from './api.js' + +function mockFetch() { + const fn = vi.fn(async () => ({ + ok: true, + status: 200, + json: async () => ({ items: [] }), + })) + global.fetch = fn + return fn +} + +afterEach(() => { vi.restoreAllMocks() }) + +describe('collection-scoped api URLs', () => { + it('listRFCs scopes to a collection when given one', async () => { + const f = mockFetch() + await listRFCs('ohm', 'features') + expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs') + }) + + it('listRFCs falls back to the project default path without a collection', async () => { + const f = mockFetch() + await listRFCs('ohm') + expect(f).toHaveBeenCalledWith('/api/projects/ohm/rfcs') + }) + + it('getRFC scopes to a collection when given one', async () => { + const f = mockFetch() + await getRFC('ohm', 'login', 'features') + expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections/features/rfcs/login') + }) + + it('proposeRFC targets the collection-scoped propose route', async () => { + const f = mockFetch() + await proposeRFC('ohm', { title: 'T', slug: 's', pitch: 'p', tags: [], collectionId: 'features' }) + expect(f.mock.calls[0][0]).toBe('/api/projects/ohm/collections/features/rfcs/propose') + }) + + it('listCollections hits the project collections route', async () => { + const f = mockFetch() + await listCollections('ohm') + expect(f).toHaveBeenCalledWith('/api/projects/ohm/collections') + }) +}) diff --git a/frontend/src/api.js b/frontend/src/api.js index 35978ca..d64efd8 100644 --- a/frontend/src/api.js +++ b/frontend/src/api.js @@ -182,22 +182,50 @@ export async function getProject(projectId) { return jsonOrThrow(await fetch(`/api/projects/${projectId}`)) } -// §22.4 (Plan B): per-project serving. Given a projectId, read the -// project-scoped routes so a non-default project's corpus renders; without -// one, fall back to the default-project compat path. -export async function listRFCs(projectId) { +// §22.4 (Plan B) / §22 S2: per-collection serving. With a projectId + a +// collectionId, read the collection-scoped routes; with only a projectId, the +// project default-collection compat path; with neither, the unscoped path. +export async function listRFCs(projectId, collectionId) { + if (projectId && collectionId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs`)) + } const url = projectId ? `/api/projects/${projectId}/rfcs` : '/api/rfcs' return jsonOrThrow(await fetch(url)) } -export async function getRFC(projectId, slug) { +export async function getRFC(projectId, slug, collectionId) { // Back-compat: getRFC(slug) (one arg) still hits the unscoped default path. if (slug === undefined) { return jsonOrThrow(await fetch(`/api/rfcs/${projectId}`)) } + if (collectionId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections/${collectionId}/rfcs/${slug}`)) + } return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`)) } +// §22 S2: the collections of a project (for the /p/<project>/ directory). +export async function listCollections(projectId) { + return jsonOrThrow(await fetch(`/api/projects/${projectId}/collections`)) +} + +// §22 S2: create-collection (deployment owner/admin). The backend commits a +// .collection.yaml and re-mirrors the registry, returning the new collection. +export async function createCollection(projectId, { collectionId, type, name, visibility, initialState }) { + const res = await fetch(`/api/projects/${projectId}/collections`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + collection_id: collectionId, + type, + name: name || null, + visibility: visibility || null, + initial_state: initialState || null, + }), + }) + return jsonOrThrow(res) +} + export async function listProposals(projectId) { const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals' return jsonOrThrow(await fetch(url)) @@ -209,8 +237,10 @@ export async function getProposal(prNumber) { // §22.4 (Plan B write): propose into a specific project when projectId is // given; else the default-project compat path. -export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) { - const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose' +export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase, collectionId }) { + const url = (projectId && collectionId) + ? `/api/projects/${projectId}/collections/${collectionId}/rfcs/propose` + : (projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose') const res = await fetch(url, { method: 'POST', headers: { 'Content-Type': 'application/json' }, diff --git a/frontend/src/components/Catalog.jsx b/frontend/src/components/Catalog.jsx index 24bc470..607ae06 100644 --- a/frontend/src/components/Catalog.jsx +++ b/frontend/src/components/Catalog.jsx @@ -9,7 +9,7 @@ import { useEffect, useMemo, useState } from 'react' import { useParams, Link } from 'react-router-dom' import { listRFCs, listProposals } from '../api' -import { entryPath, proposalPath, useProjectId } from '../lib/entryPaths' +import { entryPath, proposalPath, useProjectId, useCollectionId } from '../lib/entryPaths' const STATE_CHIPS = [ { id: 'super-draft', label: 'Super-draft' }, @@ -32,11 +32,14 @@ export default function Catalog({ viewer, onProposeRFC, version }) { const [pendingOpen, setPendingOpen] = useState(true) const { slug, prNumber } = useParams() const pid = useProjectId() + // §22 S2: the catalog is scoped to the active collection (the `/c/:cid/` + // route segment, else the project's default collection). + const cid = useCollectionId() useEffect(() => { - listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([])) + listRFCs(pid, cid).then(d => setRfcs(d.items)).catch(() => setRfcs([])) listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([])) - }, [version, pid]) + }, [version, pid, cid]) const filtered = useMemo(() => { const needle = search.trim().toLowerCase() @@ -105,7 +108,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) { return ( <Link key={r.slug} - to={entryPath(pid, r.slug)} + to={entryPath(pid, r.slug, cid)} className={`catalog-row ${isActive ? 'active' : ''} ${isSuper ? 'is-super' : ''}`} > <div className="row-top"> @@ -133,7 +136,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) { {proposals.map(p => ( <Link key={p.pr_number} - to={proposalPath(pid, p.pr_number)} + to={proposalPath(pid, p.pr_number, cid)} className={`pending-row ${String(prNumber) === String(p.pr_number) ? 'active' : ''}`} > <div>{p.title.replace(/^Propose:\s*/, '')}</div> diff --git a/frontend/src/components/CollectionDirectory.jsx b/frontend/src/components/CollectionDirectory.jsx new file mode 100644 index 0000000..1b8b762 --- /dev/null +++ b/frontend/src/components/CollectionDirectory.jsx @@ -0,0 +1,51 @@ +// §22 S2 — the project collection directory at `/p/<project>/`. Lists the +// project's caller-visible collections as cards linking into each collection's +// `/p/<project>/c/<collection>/` home. When exactly one collection is visible +// the directory is skipped and we redirect straight into it (the S1 C3.7/C3.8 +// single-collection UX, preserved). The role-keyed "Create your first +// collection" empty state is S4; S2 shows a minimal note when there are none. +import { useEffect, useState } from 'react' +import { Link, Navigate } from 'react-router-dom' +import { listCollections } from '../api' +import { collectionHome } from '../lib/entryPaths' +import { entryNoun } from './ProjectLayout.jsx' + +export default function CollectionDirectory({ projectId }) { + const [cols, setCols] = useState(null) + useEffect(() => { + let live = true + listCollections(projectId) + .then(d => { if (live) setCols(d.items) }) + .catch(() => { if (live) setCols([]) }) + return () => { live = false } + }, [projectId]) + + if (cols === null) { + return <main className="chrome-pane"><div className="boot">Loading…</div></main> + } + // C3.7/C3.8: a single visible collection skips the directory. + if (cols.length === 1) { + return <Navigate to={collectionHome(projectId, cols[0].id)} replace /> + } + return ( + <main className="chrome-pane"> + <div className="directory"> + <h1>Collections</h1> + {cols.length === 0 ? ( + <p className="directory-tagline">No collections yet.</p> + ) : ( + <ul className="directory-list"> + {cols.map(c => ( + <li key={c.id} className="directory-card"> + <Link to={collectionHome(projectId, c.id)}> + <span className="directory-card-name">{c.name || c.id}</span> + <span className="directory-card-type">{entryNoun(c.type)}s</span> + </Link> + </li> + ))} + </ul> + )} + </div> + </main> + ) +} diff --git a/frontend/src/components/CollectionDirectory.test.jsx b/frontend/src/components/CollectionDirectory.test.jsx new file mode 100644 index 0000000..ccc10b2 --- /dev/null +++ b/frontend/src/components/CollectionDirectory.test.jsx @@ -0,0 +1,48 @@ +import React from 'react' +import { describe, it, expect, vi, beforeEach } from 'vitest' +import { render, screen, waitFor } from '@testing-library/react' +import { MemoryRouter, Routes, Route } from 'react-router-dom' + +let mockItems = [] +vi.mock('../api', () => ({ + listCollections: vi.fn(async () => ({ items: mockItems })), +})) +import CollectionDirectory from './CollectionDirectory.jsx' + +beforeEach(() => { mockItems = [] }) + +function renderDir(items) { + mockItems = items + return render( + <MemoryRouter initialEntries={["/p/ohm/"]}> + <Routes> + <Route path="/p/:projectId/*" element={<CollectionDirectory projectId="ohm" />} /> + <Route path="/p/:projectId/c/:collectionId/*" element={<div>collection home</div>} /> + </Routes> + </MemoryRouter>, + ) +} + +describe('CollectionDirectory', () => { + it('lists a card per collection with the type-driven noun + link when 2+', async () => { + renderDir([ + { id: 'default', name: 'Model', type: 'document' }, + { id: 'features', name: 'Scenarios', type: 'bdd' }, + ]) + await waitFor(() => expect(screen.getByText('Scenarios')).toBeInTheDocument()) + expect(screen.getByText('Model').closest('a')).toHaveAttribute('href', '/p/ohm/c/default/') + expect(screen.getByText('Scenarios').closest('a')).toHaveAttribute('href', '/p/ohm/c/features/') + expect(screen.getByText('RFCs')).toBeInTheDocument() // document → RFCs + expect(screen.getByText('Features')).toBeInTheDocument() // bdd → Features (type noun) + }) + + it('redirects into the sole collection when exactly one is visible', async () => { + renderDir([{ id: 'default', name: 'Model', type: 'document' }]) + await waitFor(() => expect(screen.getByText('collection home')).toBeInTheDocument()) + }) + + it('shows a minimal empty note when there are no collections', async () => { + renderDir([]) + await waitFor(() => expect(screen.getByText('No collections yet.')).toBeInTheDocument()) + }) +}) diff --git a/frontend/src/components/ProposeModal.jsx b/frontend/src/components/ProposeModal.jsx index 0989a89..ef7a110 100644 --- a/frontend/src/components/ProposeModal.jsx +++ b/frontend/src/components/ProposeModal.jsx @@ -28,7 +28,7 @@ function slugify(title) { .replace(/^-+|-+$/g, '') } -export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) { +export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId, collectionId }) { // #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title // (App passes the `?propose=<term>` value here); the slug derives from it // via the same effect that drives manual typing. @@ -98,6 +98,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl pitch: pitch.trim(), tags, proposedUseCase: useCase.trim() || null, + collectionId, }) // v0.15.0 — analytics: fire on the §9.1 propose-RFC submit. // Slug is a stable, low-cardinality identifier (kebab-case diff --git a/frontend/src/lib/entryPaths.js b/frontend/src/lib/entryPaths.js index 840bf59..b780f5a 100644 --- a/frontend/src/lib/entryPaths.js +++ b/frontend/src/lib/entryPaths.js @@ -1,22 +1,30 @@ -// §22.10 — project-scoped path builders. After M3 every entry/proposal link -// lives under `/p/<project>/…`. Until Plan B serves multiple corpora, that -// project id is the deployment's corpus-served default for chrome surfaces, or -// the contextual project when a component renders inside a project subtree -// (ProjectContext). Components build links via these helpers so the later -// per-project-serving slice flips them in one place. +// §22 three-tier — project + collection-scoped path builders. The canonical +// entry route now carries the collection segment: `/p/<project>/c/<collection>/…`. +// In S1 each project has a single (default) collection and serving stays +// project-scoped, so the builders emit the default collection segment; the +// collection-aware link layer (named collections) lands in S2. Components build +// links via these helpers so that flip happens in one place. +import { useParams } from 'react-router-dom' import { useProject } from '../components/ProjectLayout.jsx' import { useDeployment } from '../context/DeploymentProvider' -export function entryPath(pid, slug) { - return `/p/${pid}/e/${slug}` +// The default collection id (migration 029 seeds one per project at this id). +export const DEFAULT_COLLECTION = 'default' + +export function entryPath(pid, slug, cid = DEFAULT_COLLECTION) { + return `/p/${pid}/c/${cid}/e/${slug}` } -export function entryPrPath(pid, slug, prNumber) { - return `/p/${pid}/e/${slug}/pr/${prNumber}` +export function entryPrPath(pid, slug, prNumber, cid = DEFAULT_COLLECTION) { + return `/p/${pid}/c/${cid}/e/${slug}/pr/${prNumber}` } -export function proposalPath(pid, prNumber) { - return `/p/${pid}/proposals/${prNumber}` +export function proposalPath(pid, prNumber, cid = DEFAULT_COLLECTION) { + return `/p/${pid}/c/${cid}/proposals/${prNumber}` +} + +export function collectionHome(pid, cid = DEFAULT_COLLECTION) { + return `/p/${pid}/c/${cid}/` } export function projectHome(pid) { @@ -30,3 +38,10 @@ export function useProjectId() { const { defaultProjectId } = useDeployment() return (ctx && ctx.projectId) || defaultProjectId } + +// §22 S2 — the collection id a component should scope to: the `/c/:collectionId/` +// route segment when present, else the project's default collection. +export function useCollectionId() { + const { collectionId } = useParams() + return collectionId || DEFAULT_COLLECTION +} diff --git a/frontend/src/lib/entryPaths.test.js b/frontend/src/lib/entryPaths.test.js new file mode 100644 index 0000000..5cbd7e9 --- /dev/null +++ b/frontend/src/lib/entryPaths.test.js @@ -0,0 +1,36 @@ +// §22 three-tier — the canonical corpus path now carries the /c/<collection>/ +// segment. These builders default to the project's `default` collection (S1). +import { describe, it, expect } from 'vitest' +import { + entryPath, entryPrPath, proposalPath, collectionHome, projectHome, DEFAULT_COLLECTION, +} from './entryPaths.js' + +describe('entryPaths — collection-scoped corpus URLs', () => { + it('entryPath defaults to the default collection segment', () => { + expect(entryPath('ohm', 'human')).toBe('/p/ohm/c/default/e/human') + }) + + it('entryPath honours an explicit collection id', () => { + expect(entryPath('ohm', 'login', 'features')).toBe('/p/ohm/c/features/e/login') + }) + + it('entryPrPath carries the collection segment', () => { + expect(entryPrPath('ohm', 'human', 7)).toBe('/p/ohm/c/default/e/human/pr/7') + }) + + it('proposalPath carries the collection segment', () => { + expect(proposalPath('ohm', 42)).toBe('/p/ohm/c/default/proposals/42') + }) + + it('collectionHome targets the collection root', () => { + expect(collectionHome('ohm')).toBe('/p/ohm/c/default/') + }) + + it('projectHome stays at the project root (redirects into the collection)', () => { + expect(projectHome('ohm')).toBe('/p/ohm/') + }) + + it('exposes the default collection id constant', () => { + expect(DEFAULT_COLLECTION).toBe('default') + }) +})