Compare commits

..

18 Commits

Author SHA1 Message Date
Ben Stull 868391870c §22 S2: collection read helpers (list/get/subfolder)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 13:00:28 -07:00
Ben Stull 74476423ba §22 S2: registry mirror reads .collection.yaml manifests
Discover named collections by walking each project's content-repo root for
<subdir>/.collection.yaml; parse + upsert with immutable-type enforcement
(§22.4a) and project-visibility inheritance. The default collection still
flows from projects.yaml.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:59:25 -07:00
Ben Stull 599e7018f6 §22 S2: implementation plan — create & navigate a second collection
Plan for slice S2 of the three-tier (deployment→project→collection) refactor.
Completes acceptance @S2 (C3.6). See
docs/design/2026-06-05-three-tier-projects-collections.md Part E.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 12:55:56 -07:00
Ben Stull 4ffff6b677 Merge pull request '§22 S1: three-tier collection grain — migration 029 + threading + 308 redirect (v0.40.0)' (#19) from feat/s1-collection-grain into main 2026-06-05 15:34:07 +00:00
Ben Stull aaf7b09bbe §22 S1: release v0.40.0 — three-tier collection grain (breaking URL + migration 029)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:31:43 -07:00
Ben Stull 4f72aa31e0 §22 S1: frontend /c/<collection>/ route layer + C3.7 + legacy-URL redirects
- entryPaths builders carry the /c/<collection>/ segment (default collection in S1)
- /p/:projectId/* gains c/:collectionId/ corpus routes; serving stays project-scoped
- DefaultCollectionRedirect (C3.7: project landing -> default collection)
- LegacyCorpusRedirect (v0.35.0 /p/<p>/e/<slug> bookmarks -> /c/default/, query preserved)
- entryPaths unit test; build + vitest green (18 tests)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:30:18 -07:00
Ben Stull 9ca07a3f81 §22 S1: thread collection_id through backend + update tests (N=1 unchanged, 454 green)
- collections.py resolution helpers (default_collection_id, type, initial_state)
- registry mirror writes project grouping fields + default-collection corpus fields
- auth.project_of_rfc joins collections; project_member_role reads memberships
- cache/api_*/funder writers+readers re-keyed to collection_id (cached_prs + denormalised
  project_id tags unchanged); api_deployment reads type/initial_state from the default collection
- projects.py restamp detects bootstrap via collections; initial_state via collection
- tests updated to the three-tier schema; test_migration_028 retired (superseded by 029)
- add @S1 acceptance test (collection grain + N=1 serving)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 08:26:14 -07:00
Ben Stull 867f2504d6 §22 S1: migration 029 — collections grain, field move-down, 13-table re-key, memberships
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:58:39 -07:00
Ben Stull 08bdea8539 §22 S1 plan: three-tier collection grain (migration 029 + threading + redirect)
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 07:53:41 -07:00
Ben Stull 0de91fe35c Merge pull request '§22 refactor (spec): three tiers — deployment → project → RFC collection' (#18) from spec/three-tier-projects-collections into main 2026-06-05 14:38:48 +00:00
Ben Stull 2f5d09aef5 §22 spec: re-cut Part E into usable BDD-tagged slices (S1-S6)
Per operator (session 0072): every slice must end in a usable deployment and
declare which Part C scenarios it makes pass. Tag all 23 Gherkin scenarios with
@S<n> (the slice that completes them) and re-cut Part E from layer-by-layer
(N1-N6) to usable increments (S1-S6) with a slice->scenario index table. S1
bundles the coupled migration 029 + threading + redirect as one right-sized
first session; S2 second collection; S3 role enforcement; S4 invitation;
S5 create-project + directory; S6 types + SPEC merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 06:42:12 -07:00
Ben Stull 87279fc545 §22 three-tier spec Part E: decided migration-029 strategy (collection grain beneath project)
Operator chose (session 0072) to add the collection grain beneath today's
project: projects table stays the group tier (keeps content_repo), a new
collections table holds the per-corpus fields, entries re-key to
(collection_id, slug), one default collection per project on migrate, breaking
/p/<project>/e/<slug> -> /p/<project>/c/<collection>/e/<slug> with 308s.
Re-sloted slices N1-N6. Correction banners updated from pending -> decided.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:46:51 -07:00
Ben Stull 9c0e3b60ac Correct §22 three-tier spec: two-tier model already shipped (v0.39.0)
Re-checked code vs the stale memory: migration 028 (slug PK -> (project_id,
slug)), v0.35.0 /p/<project>/ routing, and v0.37/0.38 per-project read+propose
are all shipped to main. The 'fold into not-yet-shipped Plan B + M3-frontend'
premise is false. Neutralize the wrong claims in §0/§A.3 and flag Part E's
sequencing as pending re-decision; structural model (Parts A-D) unaffected.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:43:44 -07:00
Ben Stull 31d680be54 §22 three-tier refactor spec: project → RFC collection + unified roles + BDDs
Splits the original §22 two-tier model (deployment → project=corpus) into
three tiers (deployment → project → RFC collection). Project owns one
content repo; collections are typed subfolders declared by .collection.yaml
manifests (git-truth). Reconciles the accumulated role vocabulary onto one
{owner, contributor} enum attached at {global, project, collection}, with
downward additive inheritance and no negative override. Adds BDD scenarios
(Part C) for role usage, invitation, and empty states. Re-slots the roadmap
to fold the tier into the not-yet-shipped Plan B (mig 028) + M3-frontend.

Session 0072 (spec). Revises docs/design/multi-project-spec.md §22.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-05 03:39:04 -07:00
Ben Stull f758fe072f Merge pull request '§22.13 step 1: default-project-id re-stamp (v0.39.0)' (#17) from feat/m3-planb-restamp into main 2026-06-04 14:45:21 +00:00
Ben Stull 33c67ccc09 §22.13 step 1: default-project-id re-stamp (v0.39.0)
projects.restamp_default_project(config): at startup after the registry mirror,
renames project_id from the M1 bootstrap 'default' to the configured
DEFAULT_PROJECT_ID across every project-scoped table (discovered by column) and
drops the stale 'default' projects row, so a deployment's original corpus lands
at a meaningful /p/<id>/ and 'default' is never a public URL. FK off for the
rename (parent+children move together) + foreign_key_check backstop. Idempotent;
no-op unless DEFAULT_PROJECT_ID is set to a non-'default' value.

test_restamp_default_project.py (3 tests). 450 backend green.

This is the last framework piece for OHM's clean /p/ohm/ cutover.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:45:10 -07:00
Ben Stull 508a8cb6d0 Merge pull request '§22 Plan B write path (propose): project-scoped propose (v0.38.0)' (#16) from feat/m3-planb-write into main 2026-06-04 14:02:29 +00:00
Ben Stull fec51bdbb6 §22 M3-backend Plan B (write path, propose): project-scoped propose (v0.38.0)
A new entry can be proposed into a specific project; it lands in that project's
content repo and shows under that project's proposals. A non-default project is
no longer read-only.

- api.py: POST /api/projects/{pid}/rfcs/propose (propose body extracted into a
  project-parameterized helper; unscoped /api/rfcs/propose kept as default
  compat). Slug uniqueness, idea-PR reservation, landing state, and the
  proposed_use_cases row scoped to the target project. GET
  /api/projects/{pid}/proposals.
- cache.py: refresh_meta_pulls loops every project's content_repo, stamping
  cached_prs.project_id; projects.content_repo(pid) helper.
- frontend: proposeRFC(projectId,…)/listProposals(projectId); ProposeModal
  takes projectId; App resolves current project from the /p/<id>/ URL; Catalog
  lists that project's proposals.
- tests: test_project_scoped_propose.py (lands scoped + gated 404). 447 backend
  + 11 Vitest green; clean build.

Known limitation: branch/PR/graduation edit flows + default-id re-stamp not yet
scoped (next slice). Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 07:02:19 -07:00
44 changed files with 4552 additions and 323 deletions
+146
View File
@@ -23,6 +23,152 @@ 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.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/<collection>/` 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
AD) 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/<collection>/` URL segment** — the canonical entry route is now
`/p/<project>/c/<collection>/e/<slug>`. The project landing
`/p/<project>/` 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/<project>/e/<slug>` URLs gain a `/c/<collection>/` segment.** The
shipped v0.35.0 `/p/<project>/e/<slug>` form is preserved by a client-side
redirect into the default collection; the pre-multi-project `/rfc/<slug>` and
`/proposals/<n>` server 308s now target `/p/<default>/c/<default>/…`.
> ### 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/<project>/c/default/e/<slug>`. Existing `/p/<project>/e/<slug>`,
> `/rfc/<slug>`, and `/proposals/<n>` 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
move its original corpus off the bootstrap `default` id onto a meaningful slug
(e.g. `ohm`) so it lands at `/p/<id>/` and `default` is never a public URL.
No-op unless `DEFAULT_PROJECT_ID` is set to a non-`default` value.**
Added:
- **`projects.restamp_default_project(config)`** — at startup, after the
registry mirror, if `DEFAULT_PROJECT_ID` resolves to a non-`default` id and
bootstrap-stamped rows still exist, it renames `project_id` from `default` to
the configured id across **every** project-scoped table (discovered by
column, so it stays correct as the schema grows) and drops the stale
`default` `projects` row (its data has moved to the configured row the
registry mirror created). The rename runs with FK enforcement off — parent
and child rows move together, so the composite FKs stay consistent — with a
`foreign_key_check` backstop before commit. Idempotent.
- **Tests:** `test_restamp_default_project.py` — data + composite-FK children
move to the new id, the stale row is dropped, FK integrity holds, the second
call is a no-op, and an unset `DEFAULT_PROJECT_ID` leaves `default` in place.
450 backend green.
Upgrade steps:
1. **MAY** set `DEFAULT_PROJECT_ID=<slug>` in the backend overlay and add the
matching project (same `id`) to `projects.yaml`. On the next deploy the
re-stamp moves the original corpus onto `<slug>` once; `default` URLs never
become public. Leave it unset to keep the `default` id (no change).
## 0.38.0 — 2026-06-04
**Minor — §22 M3-backend Plan B (write path, propose): a new entry can be
proposed *into a specific project*, landing in that project's content repo and
surfacing under that project's proposals. A non-default project is no longer
read-only. No upgrade steps; single-project deployments are unaffected.**
Added:
- **`POST /api/projects/{pid}/rfcs/propose`** — propose into a chosen project
(read-gated, then project-level contribute-gated, §22.6/§22.7). The propose
body is now a project-parameterized helper; the unscoped `/api/rfcs/propose`
stays as the default-project compat path. Slug uniqueness, the idea-PR
reservation, the landing state (§22.4b), and the `proposed_use_cases` row are
all scoped to the target project.
- **`GET /api/projects/{pid}/proposals`** — pending idea-PRs scoped to one
project.
- **Per-project PR mirror** (`app/cache.py`): `refresh_meta_pulls` iterates
every project's `content_repo`, stamping `cached_prs.project_id` (was: the
default project only). `projects.content_repo(pid)` helper added.
- **Tests:** `test_project_scoped_propose.py` — propose into a second project
lands in its content repo + shows only under its proposals (not the
default's); gated-project propose 404s a non-member. 447 backend green.
Changed:
- **Frontend:** `api.proposeRFC(projectId, …)` / `listProposals(projectId)`;
`ProposeModal` takes a `projectId`; `App` resolves the current project from
the `/p/<id>/` URL so the propose modal targets it; `Catalog` lists that
project's proposals.
Known limitation (next slice): the **edit** write flows — branch / PR /
graduation — and the **default-project-id re-stamp** (§22.13 step 1) are not
yet project-scoped (they still target the default project's content repo). Per
`docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md` §1 + §3.
## 0.37.0 — 2026-06-04
**Minor — §22 M3-backend Plan B (2/2, read path): per-project RFC serving. A
+1 -1
View File
@@ -1 +1 @@
0.37.0
0.40.0
+87 -38
View File
@@ -29,6 +29,7 @@ from . import (
api_notifications,
api_prs,
auth,
collections as collections_mod,
projects as projects_mod,
db,
device_trust as device_trust_mod,
@@ -637,13 +638,13 @@ def make_router(
unreviewed_clause = " AND unreviewed = 1 AND state = 'active'"
rows = db.conn().execute(
f"""
SELECT slug, title, state, rfc_id, repo,
owners_json, arbiters_json, tags_json,
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
AND project_id IN ({placeholders}){unreviewed_clause}
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
SELECT r.slug, r.title, r.state, r.rfc_id, r.repo,
r.owners_json, r.arbiters_json, r.tags_json,
r.last_main_commit_at, r.last_entry_commit_at, r.updated_at
FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id
WHERE r.state IN ('super-draft', 'active')
AND c.project_id IN ({placeholders}){unreviewed_clause}
ORDER BY COALESCE(r.last_main_commit_at, r.last_entry_commit_at) DESC
""",
params,
).fetchall()
@@ -685,8 +686,8 @@ def make_router(
raise HTTPException(404, "Not found")
viewer = auth.current_user(request)
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
# 404 to non-members.
auth.require_project_readable(viewer, row["project_id"])
# 404 to non-members. Recover the project via the entry's collection.
auth.require_project_readable(viewer, auth.project_of_rfc(slug))
# §13.7: a retired entry is removed from every browsing surface. The
# sole exception is a site owner, so the un-retire affordance has
# somewhere to live; everyone else gets a plain 404.
@@ -723,6 +724,8 @@ def make_router(
viewer = auth.current_user(request)
# §22.5 read gate: a gated project's catalog 404s to a non-member.
auth.require_project_readable(viewer, project_id)
# §22 S1: serve the project's default collection (the corpus grain).
collection_id = collections_mod.default_collection_id(project_id)
viewer_id = viewer.user_id if viewer else None
unreviewed_clause = ""
if unreviewed is not None and unreviewed.lower() in ("1", "true", "yes"):
@@ -734,18 +737,18 @@ def make_router(
last_main_commit_at, last_entry_commit_at, updated_at
FROM cached_rfcs
WHERE state IN ('super-draft', 'active')
AND project_id = ?{unreviewed_clause}
AND collection_id = ?{unreviewed_clause}
ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC
""",
(project_id,),
(collection_id,),
).fetchall()
starred = set()
if viewer_id is not None:
starred = {
r["rfc_slug"]
for r in db.conn().execute(
"SELECT rfc_slug FROM stars WHERE user_id = ? AND project_id = ?",
(viewer_id, project_id),
"SELECT rfc_slug FROM stars WHERE user_id = ? AND collection_id = ?",
(viewer_id, collection_id),
)
}
items = [
@@ -770,9 +773,10 @@ def make_router(
async def get_project_rfc(project_id: str, slug: str, request: Request) -> dict[str, Any]:
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
collection_id = collections_mod.default_collection_id(project_id)
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE project_id = ? AND slug = ?",
(project_id, slug),
"SELECT * FROM cached_rfcs WHERE collection_id = ? AND slug = ?",
(collection_id, slug),
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
@@ -782,10 +786,10 @@ def make_router(
uc = db.conn().execute(
"""
SELECT use_case FROM proposed_use_cases
WHERE scope = 'rfc' AND rfc_slug = ? AND project_id = ?
WHERE scope = 'rfc' AND rfc_slug = ? AND collection_id = ?
ORDER BY id DESC LIMIT 1
""",
(slug, project_id),
(slug, collection_id),
).fetchone()
payload["proposed_use_case"] = uc["use_case"] if uc else None
return payload
@@ -802,9 +806,10 @@ def make_router(
auth.require_project_readable(viewer, project_id)
if not auth.is_project_superuser(viewer, project_id):
raise HTTPException(403, "Only a project owner/admin can mark an entry reviewed")
collection_id = collections_mod.default_collection_id(project_id)
row = db.conn().execute(
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND project_id = ?",
(slug, project_id),
"SELECT state, unreviewed FROM cached_rfcs WHERE slug = ? AND collection_id = ?",
(slug, collection_id),
).fetchone()
if row is None:
raise HTTPException(404, "Not found")
@@ -870,6 +875,35 @@ def make_router(
]
}
@router.get("/api/projects/{project_id}/proposals")
async def list_project_proposals(project_id: str, request: Request) -> dict[str, Any]:
# §22.4/§22.5: the pending idea-PRs scoped to one project.
viewer = auth.current_user(request)
auth.require_project_readable(viewer, project_id)
rows = db.conn().execute(
"""
SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state
FROM cached_prs
WHERE pr_kind = 'idea' AND state = 'open' AND project_id = ?
ORDER BY opened_at DESC
""",
(project_id,),
).fetchall()
return {
"items": [
{
"slug": r["rfc_slug"],
"pr_number": r["pr_number"],
"title": r["title"],
"description": r["description"],
"opened_by": r["opened_by"],
"opened_at": r["opened_at"],
"proposed_use_case": _proposal_use_case(r["pr_number"]),
}
for r in rows
]
}
@router.get("/api/proposals/{pr_number}")
async def get_proposal(pr_number: int, request: Request) -> dict[str, Any]:
"""§9.3 pending-idea view data.
@@ -922,14 +956,11 @@ def make_router(
# §9.1: propose a new RFC
# ---------------------------------------------------------------
@router.post("/api/rfcs/propose")
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
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. Through M2 every entry lands in the default project; M3's
# routing carries the target project. On the public default project the
# 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, auth.DEFAULT_PROJECT_ID):
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):
@@ -939,14 +970,16 @@ def make_router(
# We re-check atomically here even though the client also checks
# on every keystroke, since a concurrent submission could land
# between dialog-open and submit.
collection_id = collections_mod.default_collection_id(project_id)
clash = db.conn().execute(
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
"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")
idea_clash = db.conn().execute(
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' AND rfc_slug = ?",
(slug,),
"SELECT 1 FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' "
"AND rfc_slug = ? AND project_id = ?",
(slug, project_id),
).fetchone()
if idea_clash:
raise HTTPException(409, f"Slug `{slug}` is already reserved by an open proposal")
@@ -954,7 +987,7 @@ def make_router(
# §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 = projects_mod.resolved_default_id(config)
target_project = project_id
landing_state = "active" if projects_mod.project_initial_state(target_project) == "active" else "super-draft"
entry = entry_mod.Entry(
@@ -990,7 +1023,7 @@ def make_router(
pr = await bot.open_idea_pr(
user.as_actor(),
org=config.gitea_org,
meta_repo=(projects_mod.default_content_repo(config) or ""),
meta_repo=(projects_mod.content_repo(project_id) or ""),
slug=slug,
file_contents=contents,
pr_title=pr_title,
@@ -1014,19 +1047,35 @@ def make_router(
if use_case:
db.conn().execute(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('rfc', ?, ?, ?)
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case, collection_id)
VALUES ('rfc', ?, ?, ?, ?)
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
(slug, pr["number"], use_case, collection_id),
)
db.conn().execute(
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ?",
(use_case, pr["number"]),
"UPDATE cached_prs SET proposed_use_case = ? WHERE pr_kind = 'idea' AND pr_number = ? AND project_id = ?",
(use_case, pr["number"], project_id),
)
return {"pr_number": pr["number"], "slug": slug}
@router.post("/api/rfcs/propose")
async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]:
# Default-project compat path (pre-multi-project clients).
user = auth.require_contributor(request)
return await _propose_into_project(projects_mod.resolved_default_id(config), payload, user)
@router.post("/api/projects/{project_id}/rfcs/propose")
async def propose_project_rfc(
project_id: str, payload: ProposeBody, request: Request
) -> dict[str, Any]:
# §22.4: propose a new entry into a specific project (read-gated first
# so a gated project 404s a non-member before the contribute check).
user = auth.require_contributor(request)
auth.require_project_readable(user, project_id)
return await _propose_into_project(project_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
@@ -1162,12 +1211,12 @@ def make_router(
async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]:
user = auth.require_contributor(request)
rfc = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
auth.require_project_readable(user, rfc["project_id"])
auth.require_project_readable(user, auth.project_of_rfc(slug))
# §6.7: refuse consent from a user with no registered credentials
# — a consent without a universe would be inert and the surface
# should fail loudly rather than silently.
+3 -3
View File
@@ -742,7 +742,7 @@ def make_router(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, ?, ?)
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
read_public = excluded.read_public,
contribute_mode = excluded.contribute_mode
""",
@@ -896,7 +896,7 @@ def make_router(
"""
INSERT INTO branch_chat_seen (user_id, rfc_slug, branch_name, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, datetime('now'))
ON CONFLICT(project_id, user_id, rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, user_id, rfc_slug, branch_name) DO UPDATE SET
last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at
""",
@@ -1092,7 +1092,7 @@ def make_router(
# ------------------------------------------------------------------
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): a gated project's entries
+2 -2
View File
@@ -62,7 +62,7 @@ def _require_super_draft(slug: str, viewer):
visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7)."""
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
"SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
@@ -108,7 +108,7 @@ def make_router() -> APIRouter:
@router.get("/api/rfcs/{slug}/contribution-target")
async def contribution_target(slug: str, request: Request) -> dict[str, Any]:
row = db.conn().execute(
"SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?",
"SELECT slug, title, state, owners_json, proposed_by, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?",
(slug,),
).fetchone()
if row is None:
+27 -10
View File
@@ -18,7 +18,7 @@ from typing import Any
from fastapi import APIRouter, HTTPException, Request
from fastapi.responses import RedirectResponse
from . import auth, db, projects as projects_mod
from . import auth, collections as collections_mod, db, projects as projects_mod
from .config import Config
@@ -33,12 +33,21 @@ def make_router(config: Config) -> APIRouter:
).fetchone()
# §22.5: enumerate only public + (member-)gated; unlisted is never listed.
visible = set(auth.visible_project_ids(viewer))
# §22 three-tier: `type` is a per-corpus field on the (default) collection
# now; surface the default collection's type for each project.
rows = db.conn().execute(
"SELECT id, name, type, visibility FROM projects "
"SELECT id, name, visibility FROM projects "
"WHERE visibility != 'unlisted' ORDER BY name"
).fetchall()
projects = [
{"id": r["id"], "name": r["name"], "type": r["type"], "visibility": r["visibility"]}
{
"id": r["id"],
"name": r["name"],
"type": collections_mod.collection_type(
collections_mod.default_collection_id(r["id"])
),
"visibility": r["visibility"],
}
for r in rows
if r["id"] in visible
]
@@ -60,8 +69,7 @@ def make_router(config: Config) -> APIRouter:
# an unknown id). unlisted is readable by direct id.
auth.require_project_readable(viewer, project_id)
row = db.conn().execute(
"SELECT id, name, type, visibility, initial_state, config_json "
"FROM projects WHERE id = ?",
"SELECT id, name, visibility, config_json FROM projects WHERE id = ?",
(project_id,),
).fetchone()
if row is None:
@@ -71,13 +79,16 @@ def make_router(config: Config) -> APIRouter:
except (ValueError, TypeError):
cfg = {}
dep = db.conn().execute("SELECT tagline FROM deployment WHERE id = 1").fetchone()
# §22 three-tier: type + initial_state moved down to the (default)
# collection in migration 029.
cid = collections_mod.default_collection_id(row["id"])
return {
"id": row["id"],
"name": row["name"],
"tagline": (dep["tagline"] if dep else "") or "",
"type": row["type"],
"type": collections_mod.collection_type(cid),
"visibility": row["visibility"],
"initial_state": row["initial_state"],
"initial_state": collections_mod.collection_initial_state(cid),
"theme": cfg.get("theme") or {},
}
@@ -86,21 +97,27 @@ def make_router(config: Config) -> APIRouter:
# is permanent, so external "RFC-0001" links and bookmarks land correctly.
# nginx routes /rfc/ and /proposals/ to the backend so these are reached
# before the SPA's index.html fallback.
# §22 three-tier (S1): the canonical entry route now carries the collection
# segment /p/<project>/c/<collection>/…. The legacy roots redirect through
# the default project's default collection.
@router.get("/rfc/{slug}")
async def redirect_old_rfc(slug: str) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(url=f"/p/{default_id}/e/{slug}", status_code=308)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/e/{slug}", status_code=308)
@router.get("/rfc/{slug}/pr/{pr_number}")
async def redirect_old_rfc_pr(slug: str, pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(
url=f"/p/{default_id}/e/{slug}/pr/{pr_number}", status_code=308
url=f"/p/{default_id}/c/{cid}/e/{slug}/pr/{pr_number}", status_code=308
)
@router.get("/proposals/{pr_number}")
async def redirect_old_proposal(pr_number: int) -> RedirectResponse:
default_id = projects_mod.resolved_default_id(config)
return RedirectResponse(url=f"/p/{default_id}/proposals/{pr_number}", status_code=308)
cid = collections_mod.default_collection_id(default_id)
return RedirectResponse(url=f"/p/{default_id}/c/{cid}/proposals/{pr_number}", status_code=308)
return router
+1 -1
View File
@@ -252,7 +252,7 @@ def _require_rfc_readable(slug: str, viewer):
entries refuse reads of every shape — same rule `_require_rfc_with_repo`
in `api_branches.py` follows."""
row = db.conn().execute(
"SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)
"SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
+3 -3
View File
@@ -574,7 +574,7 @@ def make_router(
# -------------------------------------------------------------------
def _require_super_draft(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members.
@@ -584,7 +584,7 @@ def make_router(
return row
def _require_retirable(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] not in ("super-draft", "active"):
@@ -592,7 +592,7 @@ def make_router(
return row
def _require_retired(slug: str):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
if row["state"] != "retired":
+1 -1
View File
@@ -380,7 +380,7 @@ def _require_rfc(slug: str, viewer):
visibility gate is subtractive: a gated project's entries 404 to
non-members (§22.7)."""
row = db.conn().execute(
"SELECT slug, title, state, project_id FROM cached_rfcs WHERE slug = ?", (slug,),
"SELECT slug, title, state, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,),
).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
+2 -2
View File
@@ -213,7 +213,7 @@ def make_router(config: Config) -> APIRouter:
@router.post("/api/rfcs/{slug}/watch")
async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]:
viewer = auth.require_user(request)
rfc = db.conn().execute("SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
rfc = db.conn().execute("SELECT (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if rfc is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive): gated → 404 to non-members.
@@ -222,7 +222,7 @@ def make_router(config: Config) -> APIRouter:
"""
INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at)
VALUES (?, ?, ?, 'explicit', datetime('now'), datetime('now'))
ON CONFLICT(project_id, user_id, rfc_slug) DO UPDATE SET
ON CONFLICT(collection_id, user_id, rfc_slug) DO UPDATE SET
state = excluded.state,
set_by = 'explicit',
set_at = excluded.set_at
+4 -4
View File
@@ -153,7 +153,7 @@ def make_router(
"""
INSERT INTO branch_visibility (rfc_slug, branch_name, read_public, contribute_mode)
VALUES (?, ?, 1, 'just-me')
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET read_public = 1
""",
(slug, branch),
)
@@ -189,7 +189,7 @@ def make_router(
"""
INSERT INTO proposed_use_cases (scope, rfc_slug, pr_number, use_case)
VALUES ('pr', ?, ?, ?)
ON CONFLICT(project_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
ON CONFLICT(collection_id, scope, pr_number) DO UPDATE SET use_case = excluded.use_case
""",
(slug, pr["number"], use_case),
)
@@ -398,7 +398,7 @@ def make_router(
INSERT INTO pr_seen
(user_id, rfc_slug, pr_number, last_seen_commit_sha, last_seen_message_id, seen_at)
VALUES (?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT(project_id, user_id, rfc_slug, pr_number) DO UPDATE SET
ON CONFLICT(collection_id, user_id, rfc_slug, pr_number) DO UPDATE SET
last_seen_commit_sha = excluded.last_seen_commit_sha,
last_seen_message_id = excluded.last_seen_message_id,
seen_at = excluded.seen_at
@@ -662,7 +662,7 @@ def make_router(
# ------------------------------------------------------------------
def _require_rfc(slug: str, viewer):
row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
row = db.conn().execute("SELECT *, (SELECT c.project_id FROM collections c WHERE c.id = cached_rfcs.collection_id) AS project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone()
if row is None:
raise HTTPException(404, "RFC not found")
# §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always
+27 -10
View File
@@ -337,24 +337,41 @@ def project_visibility(project_id: str) -> str:
def project_member_role(user: SessionUser | None, project_id: str) -> str | None:
"""The user's *explicit* §22.6 project_members role in this project, or
None. This is the stored row only — it does not fold in the deployment tier
or the implicit-on-public baseline (those live in the helpers below)."""
"""The user's *explicit* §22.6 membership role at this project, or None.
§22 three-tier (S1): M2's `project_members` rows migrated into the unified
`memberships` table at the project's default collection (and the project
tier is freshly grantable). The unified `{owner, contributor}` roles are
mapped back to the legacy `project_admin`/`project_contributor` strings the
S1 project-grain authz still speaks; the four-layer scope resolver lands in
S3. Reads the stored grant only (project OR default-collection scope) — it
does not fold in the deployment tier or the implicit-on-public baseline."""
if user is None:
return None
from . import collections as collections_mod
cid = collections_mod.default_collection_id(project_id)
row = db.conn().execute(
"SELECT role FROM project_members WHERE project_id = ? AND user_id = ?",
(project_id, user.user_id),
"SELECT role FROM memberships "
"WHERE user_id = ? AND ((scope_type = 'project' AND scope_id = ?) "
" OR (scope_type = 'collection' AND scope_id = ?)) "
"ORDER BY CASE role WHEN 'owner' THEN 0 ELSE 1 END LIMIT 1",
(user.user_id, project_id, cid),
).fetchone()
return row["role"] if row else None
if row is None:
return None
return "project_admin" if row["role"] == "owner" else "project_contributor"
def project_of_rfc(rfc_slug: str) -> str:
"""The project an RFC belongs to (`cached_rfcs.project_id`). Falls back to
the default project when the slug isn't cached or the column is unset — the
same N=1 default migration 026 backfills."""
"""The project an RFC belongs to, via its collection
(`cached_rfcs.collection_id` -> `collections.project_id`, §22 three-tier).
Falls back to the default project when the slug isn't cached — the same N=1
default migration 026 backfills."""
row = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,)
"SELECT c.project_id AS project_id "
"FROM cached_rfcs r JOIN collections c ON c.id = r.collection_id "
"WHERE r.slug = ?",
(rfc_slug,),
).fetchone()
if row is None:
return DEFAULT_PROJECT_ID
+31 -17
View File
@@ -54,6 +54,11 @@ async def refresh_meta_repo(config: Config, gitea: Gitea) -> None:
async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: Gitea) -> None:
# §22 S1: the corpus grain is the collection. The mirror is project-grained
# (reads rfcs/ at the repo root = the project's default collection); resolve
# that collection once and key cached_rfcs by it.
from . import collections as collections_mod
collection_id = collections_mod.default_collection_id(project_id)
try:
files = await gitea.list_dir(org, repo, "rfcs", ref="main")
except GiteaError as e:
@@ -77,7 +82,7 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
log.warning("refresh_meta_repo: %s: skipping %s: missing slug", project_id, f["path"])
continue
seen_slugs.add(entry.slug)
_upsert_cached_rfc(entry, body_sha=sha, project_id=project_id)
_upsert_cached_rfc(entry, body_sha=sha, collection_id=collection_id)
# Entries removed from a project's rfcs/ — the spec keeps withdrawn entries
# as historical record (§3), so this fires only for out-of-band deletes;
@@ -85,14 +90,14 @@ async def _refresh_project_corpus(org: str, project_id: str, repo: str, gitea: G
existing = {
row["slug"]
for row in db.conn().execute(
"SELECT slug FROM cached_rfcs WHERE project_id = ?", (project_id,)
"SELECT slug FROM cached_rfcs WHERE collection_id = ?", (collection_id,)
)
}
for missing in existing - seen_slugs:
log.info("refresh_meta_repo: %s/%s no longer in rfcs/ — leaving cache row", project_id, missing)
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str = "default") -> None:
def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, collection_id: str = "default") -> None:
# §6.6: models_json stays NULL when the frontmatter key is absent
# (inherit operator universe) and '[]' for the explicit opt-out.
models_json = json.dumps(entry.models) if entry.models is not None else None
@@ -105,10 +110,10 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str =
(slug, title, state, rfc_id, repo, proposed_by, proposed_at,
graduated_at, graduated_by, owners_json, arbiters_json, tags_json,
models_json, funder_login, body, body_sha,
unreviewed, reviewed_at, reviewed_by, project_id,
unreviewed, reviewed_at, reviewed_by, collection_id,
last_entry_commit_at, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'), datetime('now'))
ON CONFLICT(project_id, slug) DO UPDATE SET
ON CONFLICT(collection_id, slug) DO UPDATE SET
title = excluded.title,
state = excluded.state,
rfc_id = excluded.rfc_id,
@@ -150,7 +155,7 @@ def _upsert_cached_rfc(entry: entry_mod.Entry, body_sha: str, project_id: str =
1 if entry.unreviewed else 0,
entry.reviewed_at,
entry.reviewed_by,
project_id,
collection_id,
),
)
@@ -210,7 +215,7 @@ async def refresh_rfc_repo(config: Config, gitea: Gitea, slug: str) -> None:
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
@@ -385,7 +390,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, ?, ?, 'open', ?)
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
state = CASE WHEN cached_branches.state = 'closed' THEN 'closed' ELSE 'open' END,
last_commit_at = excluded.last_commit_at
@@ -407,7 +412,7 @@ async def refresh_meta_branches(config: Config, gitea: Gitea) -> None:
"""
INSERT INTO cached_branches (rfc_slug, branch_name, head_sha, state, last_commit_at)
VALUES (?, 'main', ?, 'open', ?)
ON CONFLICT(project_id, rfc_slug, branch_name) DO UPDATE SET
ON CONFLICT(collection_id, rfc_slug, branch_name) DO UPDATE SET
head_sha = excluded.head_sha,
last_commit_at = excluded.last_commit_at
""",
@@ -468,20 +473,28 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
login as last resort.
"""
org = config.gitea_org
repo = projects_mod.default_content_repo(config)
if not repo:
log.warning("refresh_meta_pulls: default project has no content_repo yet; skipping")
bot_login = config.gitea_bot_user
rows = db.conn().execute(
"SELECT id, content_repo FROM projects WHERE content_repo IS NOT NULL AND content_repo != ''"
).fetchall()
if not rows:
log.warning("refresh_meta_pulls: no projects with a content_repo yet; skipping")
return
for prow in rows:
await _refresh_project_pulls(org, prow["id"], prow["content_repo"], gitea, bot_login)
async def _refresh_project_pulls(
org: str, project_id: str, repo: str, gitea: Gitea, bot_login: str
) -> None:
repo_full = f"{org}/{repo}"
try:
open_pulls = await gitea.list_pulls(org, repo, state="open")
closed_pulls = await gitea.list_pulls(org, repo, state="closed")
except GiteaError as e:
log.warning("refresh_meta_pulls: %s", e)
log.warning("refresh_meta_pulls: project %s: %s", project_id, e)
return
bot_login = config.gitea_bot_user
for pull in open_pulls + closed_pulls:
head_branch = pull.get("head", {}).get("ref", "")
# A merged-and-deleted PR's branch is no longer reported by Gitea
@@ -536,8 +549,8 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
INSERT INTO cached_prs
(rfc_slug, pr_kind, repo, pr_number, title, description, state,
opened_by, opened_at, merged_at, closed_at,
head_branch, base_branch, head_sha, merge_commit_sha)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
head_branch, base_branch, head_sha, merge_commit_sha, project_id)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON CONFLICT(repo, pr_number) DO UPDATE SET
title = excluded.title,
description = excluded.description,
@@ -564,6 +577,7 @@ async def refresh_meta_pulls(config: Config, gitea: Gitea) -> None:
(pull.get("base") or {}).get("ref") or "main",
(pull.get("head") or {}).get("sha"),
merge_commit_sha,
project_id,
),
)
+86
View File
@@ -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
+1 -1
View File
@@ -220,7 +220,7 @@ def add_consent(user_id: int, slug: str) -> None:
db.conn().execute(
"""
INSERT INTO funder_consents (user_id, rfc_slug) VALUES (?, ?)
ON CONFLICT(project_id, user_id, rfc_slug) DO NOTHING
ON CONFLICT(collection_id, user_id, rfc_slug) DO NOTHING
""",
(user_id, slug),
)
+5
View File
@@ -112,6 +112,11 @@ async def lifespan(app: FastAPI):
raise RuntimeError(
f"registry mirror failed at startup ({config.registry_repo_full}/projects.yaml): {e}"
) from e
# §22.13 step 1: re-stamp the M1 bootstrap 'default' project id to the
# deployment's configured id (DEFAULT_PROJECT_ID) once the registry row
# exists, so the original corpus lands at a meaningful /p/<id>/ and
# 'default' is never a public URL. Idempotent no-op once done.
projects.restamp_default_project(config)
if projects.default_content_repo(config) is None:
raise RuntimeError(
f"registry does not describe the default project "
+87 -7
View File
@@ -7,9 +7,13 @@ the registry mirror (`registry.refresh_registry`) is authoritative.
"""
from __future__ import annotations
import logging
from . import db
from .config import Config
log = logging.getLogger(__name__)
DEFAULT_PROJECT_ID = "default"
@@ -20,6 +24,74 @@ def resolved_default_id(config: Config) -> str:
return config.default_project_id.strip() or DEFAULT_PROJECT_ID
def restamp_default_project(config: Config) -> None:
"""§22.13 step 1 — one-time rename of the M1 bootstrap project id
(DEFAULT_PROJECT_ID = 'default') to the deployment's configured default id
(the DEFAULT_PROJECT_ID env var, e.g. 'ohm'), so the deployment's original
corpus lands at a meaningful `/p/<id>/` and `default` is never a public URL.
Renames `project_id` across every project-scoped table (discovered by
column, so it stays correct as the schema grows), then drops the stale
bootstrap `projects` row (its data has moved to the configured row, which
the registry mirror already created). Idempotent and a no-op when the
configured id is still 'default' or no bootstrap rows remain. Runs at
startup after the registry mirror, with FK enforcement off for the rename
(the composite FKs are kept consistent because parent and child rows are
renamed together) and a foreign_key_check backstop before commit.
"""
target = resolved_default_id(config)
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 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,)
).fetchone()
if not has_rows and not stale_proj:
return
if conn.execute("SELECT 1 FROM projects WHERE id = ? LIMIT 1", (target,)).fetchone() is None:
log.warning("restamp: target project %r not in registry yet; skipping", target)
return
tables = [r["name"] for r in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")]
pid_tables = [
t for t in tables
if any(c["name"] == "project_id" for c in conn.execute(f"PRAGMA table_info({t})"))
]
conn.execute("PRAGMA foreign_keys = OFF")
try:
conn.execute("BEGIN")
for t in pid_tables:
conn.execute(
f"UPDATE {t} SET project_id = ? WHERE project_id = ?",
(target, DEFAULT_PROJECT_ID),
)
# The bootstrap row's data has moved to the configured (registry) row.
conn.execute("DELETE FROM projects WHERE id = ?", (DEFAULT_PROJECT_ID,))
violations = conn.execute("PRAGMA foreign_key_check").fetchall()
if violations:
conn.execute("ROLLBACK")
raise RuntimeError(
f"restamp left foreign-key violations: {[tuple(v) for v in violations]}"
)
conn.execute("COMMIT")
except Exception:
try:
conn.execute("ROLLBACK")
except Exception:
pass
raise
finally:
conn.execute("PRAGMA foreign_keys = ON")
log.info("restamp: renamed bootstrap project %r -> %r across %d tables",
DEFAULT_PROJECT_ID, target, len(pid_tables))
def default_content_repo(config: Config) -> str | None:
"""The content repo the single-corpus mirror reads, from the default
project's row (filled by the registry mirror). Replaces the retired
@@ -31,12 +103,20 @@ def default_content_repo(config: Config) -> str | None:
return row["content_repo"] if row and row["content_repo"] else 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)."""
def content_repo(project_id: str) -> str | None:
"""The content repo for a specific project (§22.3). None if unknown/unset.
The per-project successor to `default_content_repo` for the write path."""
row = db.conn().execute(
"SELECT initial_state FROM projects WHERE id = ?", (project_id,)
"SELECT content_repo FROM projects WHERE id = ?", (project_id,)
).fetchone()
if row is None or not row["initial_state"]:
return "super-draft"
return row["initial_state"]
return row["content_repo"] if row and row["content_repo"] else None
def project_initial_state(project_id: str) -> str:
"""§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)
)
+156 -20
View File
@@ -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,71 @@ def apply_registry(doc: RegistryDoc, registry_sha: str) -> None:
)
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."""
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),
)
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 +314,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)
+427
View File
@@ -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;
+14 -7
View File
@@ -9,11 +9,18 @@ from test_propose_vertical import ( # noqa: F401
def _add_project(pid, name, vis, typ="document"):
# §22 three-tier: a project (grouping tier) + its default collection (the
# per-corpus type/initial_state moved down in migration 029). The default
# collection keys by the project id so it is globally unique in tests.
from app import db
db.conn().execute(
"INSERT OR REPLACE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, ?, ?, ?, 'super-draft')",
(pid, name, typ, pid, vis),
"INSERT OR REPLACE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
(pid, name, pid, vis),
)
db.conn().execute(
"INSERT OR REPLACE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES (?, ?, ?, '', 'super-draft', ?, ?)",
(pid, pid, typ, vis, name),
)
@@ -93,7 +100,7 @@ def test_rfc_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
with TestClient(app) as client:
r = client.get("/rfc/human", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/e/human"
assert r.headers["location"] == "/p/default/c/default/e/human"
def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
@@ -102,7 +109,7 @@ def test_rfc_pr_url_redirects_308_to_project_scoped(app_with_fake_gitea):
with TestClient(app) as client:
r = client.get("/rfc/human/pr/7", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/e/human/pr/7"
assert r.headers["location"] == "/p/default/c/default/e/human/pr/7"
def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea):
@@ -110,7 +117,7 @@ def test_proposals_root_url_redirects_308_to_project_scoped(app_with_fake_gitea)
with TestClient(app) as client:
r = client.get("/proposals/42", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/proposals/42"
assert r.headers["location"] == "/p/default/c/default/proposals/42"
def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
@@ -120,7 +127,7 @@ def test_gated_project_visible_and_readable_to_member(app_with_fake_gitea):
_add_project("teamx", "Team X", "gated")
provision_user_row(user_id=5, login="mia", role="contributor")
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) VALUES ('teamx', 5, 'project_viewer')"
"INSERT INTO memberships (scope_type, scope_id, user_id, role) VALUES ('collection', 'teamx', 5, 'contributor')"
)
sign_in_as(client, user_id=5, gitea_login="mia", display_name="Mia", role="contributor")
# member sees the gated project in the deployment directory
+64
View File
@@ -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
+146
View File
@@ -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"
+1 -1
View File
@@ -33,7 +33,7 @@ def test_active_initial_state_lands_active_unreviewed(app_with_fake_gitea):
from app import db, entry as entry_mod
app, fake = app_with_fake_gitea
with TestClient(app) as client:
db.conn().execute("UPDATE projects SET initial_state='active' WHERE id='default'")
db.conn().execute("UPDATE collections SET initial_state='active' WHERE project_id='default'")
provision_user_row(user_id=1, login="ben", role="owner")
sign_in_as(client, user_id=1, gitea_login="ben", display_name="Ben", role="owner")
assert _propose(client).status_code == 200
+8 -3
View File
@@ -21,12 +21,17 @@ def _fresh_config() -> Config:
def test_027_adds_project_type_and_initial_state():
# 027 added type/initial_state to `projects`; migration 029 (three-tier)
# moved those per-corpus fields *down* onto `collections`. After the full
# migration chain they live on the collection, not the project.
cfg = _fresh_config()
db.run_migrations(cfg)
conn = db.connect(cfg.database_path)
cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(projects)")}
assert "type" in cols and cols["type"]["dflt_value"] == "'document'"
assert "initial_state" in cols and cols["initial_state"]["dflt_value"] == "'super-draft'"
proj_cols = {r["name"] for r in conn.execute("PRAGMA table_info(projects)")}
assert "type" not in proj_cols and "initial_state" not in proj_cols
coll_cols = {r["name"]: r for r in conn.execute("PRAGMA table_info(collections)")}
assert "type" in coll_cols and coll_cols["type"]["dflt_value"] == "'document'"
assert "initial_state" in coll_cols and coll_cols["initial_state"]["dflt_value"] == "'super-draft'"
conn.close()
@@ -1,95 +0,0 @@
"""§22.13 / migration 028 — the slug-keyed PK/UNIQUE rebuild that activates
project #2. Proves two projects can hold the same slug, that (project_id, slug)
is still unique within a project, that the rebuilt FK is composite + enforced,
and that the no-foreign-keys migration runner left no dangling references."""
from __future__ import annotations
import sqlite3
import tempfile
from pathlib import Path
import pytest
from app import db
class _Cfg:
def __init__(self, path):
self.database_path = path
def _fresh_db():
d = tempfile.mkdtemp()
path = Path(d) / "t.db"
db.run_migrations(_Cfg(str(path)))
return db.connect(str(path))
def _seed_two_projects(conn):
for pid in ("default", "ecomm"):
conn.execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, 'document', ?, 'public', 'super-draft')",
(pid, pid.title(), pid + "-content"),
)
def test_same_slug_coexists_across_projects():
conn = _fresh_db()
_seed_two_projects(conn)
for pid in ("default", "ecomm"):
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', ?)",
(pid,),
)
rows = conn.execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'intro' ORDER BY project_id"
).fetchall()
assert [r["project_id"] for r in rows] == ["default", "ecomm"]
def test_slug_still_unique_within_a_project():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', 'default')"
)
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Dup', 'active', 'default')"
)
def test_rfc_collaborators_composite_fk_enforced():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
conn.execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) "
"VALUES ('intro', 'Intro', 'active', 'ecomm')"
)
# Matching (project_id, slug) — FK holds.
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
"VALUES ('intro', 1, 'contributor', 'ecomm')"
)
# Same slug but a project with no such entry — composite FK must reject.
with pytest.raises(sqlite3.IntegrityError):
conn.execute(
"INSERT INTO rfc_collaborators (rfc_slug, user_id, role_in_rfc, project_id) "
"VALUES ('intro', 1, 'contributor', 'default')"
)
def test_stars_unique_now_scoped_by_project():
conn = _fresh_db()
_seed_two_projects(conn)
conn.execute("INSERT INTO users (id, gitea_login, display_name, role) VALUES (1, 'a', 'A', 'contributor')")
# Same (user, slug) under two projects coexist; a duplicate within one rejects.
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'ecomm')")
with pytest.raises(sqlite3.IntegrityError):
conn.execute("INSERT INTO stars (user_id, rfc_slug, project_id) VALUES (1, 'intro', 'default')")
@@ -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')")
@@ -64,39 +64,55 @@ def _set_visibility(project_id: str, visibility: str) -> None:
)
def _add_member(project_id: str, user_id: int, role: str) -> None:
from app import db
# §22 three-tier (§B.3): M2's three project roles collapse to {owner,
# contributor} in the unified `memberships` table at the project's default
# collection. The read-only `viewer` tier is deferred (folded into contributor
# for this pass), so the legacy role names map: admin→owner, contributor and
# viewer→contributor.
_ROLE_MAP = {
"project_admin": "owner",
"project_contributor": "contributor",
"project_viewer": "contributor",
}
def _add_member(project_id: str, user_id: int, role: str) -> None:
from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute(
"INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)",
(project_id, user_id, role),
"INSERT OR REPLACE INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('collection', ?, ?, ?)",
(cid, user_id, _ROLE_MAP[role]),
)
def _remove_member(project_id: str, user_id: int) -> None:
from app import db
from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute(
"DELETE FROM project_members WHERE project_id = ? AND user_id = ?",
(project_id, user_id),
"DELETE FROM memberships WHERE scope_type = 'collection' AND scope_id = ? AND user_id = ?",
(cid, user_id),
)
def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None:
"""A minimal cached_rfcs row — enough for the authz gates (state, owners,
project_id). project_id defaults to 'default' via migration 026 but we set
it explicitly for clarity."""
collection grain). The entry lands in the project's default collection
(id == project_id for the single 'default' project under test)."""
import json
from app import db
from app import collections as collections_mod, db
cid = collections_mod.default_collection_id(project_id)
db.conn().execute(
"""
INSERT OR REPLACE INTO cached_rfcs
(slug, title, state, owners_json, arbiters_json, tags_json, project_id)
(slug, title, state, owners_json, arbiters_json, tags_json, collection_id)
VALUES (?, ?, ?, ?, '[]', '[]', ?)
""",
(slug, slug.capitalize(), state, json.dumps(owners or []), project_id),
(slug, slug.capitalize(), state, json.dumps(owners or []), cid),
)
@@ -154,18 +170,16 @@ def test_resolver_gated_project_requires_membership(app_with_fake_gitea):
assert auth.can_read_project(owner, "default") is True
assert auth.is_project_superuser(owner, "default") is True
# project_viewer → read + discuss, but not contribute.
_add_member("default", 1, "project_viewer")
# §22 three-tier (§B.3): the read-only viewer tier is deferred — the
# smallest grant is `contributor`, which grants read + discuss +
# contribute across the subtree.
_add_member("default", 1, "project_contributor")
assert auth.can_read_project(contributor, "default") is True
assert auth.can_discuss_in_project(contributor, "default") is True
assert auth.can_contribute_in_project(contributor, "default") is False
# project_contributor → contribute.
_add_member("default", 1, "project_contributor")
assert auth.can_contribute_in_project(contributor, "default") is True
assert auth.is_project_superuser(contributor, "default") is False
# project_admin → superuser within the project.
# project_admin → owner → superuser within the project.
_add_member("default", 1, "project_admin")
assert auth.is_project_superuser(contributor, "default") is True
@@ -270,7 +284,7 @@ def test_gated_propose_requires_project_contributor(app_with_fake_gitea):
assert client.post("/api/rfcs/propose", json=body).status_code != 403
def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea):
def test_gated_member_can_discuss_and_contribute(app_with_fake_gitea):
from app import auth
app, _ = app_with_fake_gitea
@@ -285,14 +299,11 @@ def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea
sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor")
assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404
# project_viewer: can discuss (200) but cannot contribute (resolver).
_add_member("default", 2, "project_viewer")
# §22 three-tier (§B.3): a `contributor` member can both discuss and
# contribute (the viewer-only read tier is deferred this pass).
_add_member("default", 2, "project_contributor")
r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"})
assert r.status_code == 200, r.text
assert auth.can_contribute_to_rfc(bob, "spec") is False
# project_contributor: can contribute.
_add_member("default", 2, "project_contributor")
assert auth.can_contribute_to_rfc(bob, "spec") is True
@@ -16,14 +16,18 @@ from test_propose_vertical import ( # noqa: F401
tmp_env,
)
# The 19 tables migration 026 threads project_id onto (docs/design/
# multi-project-spec.md §5 amendment list).
SLUG_TABLES = [
"cached_rfcs", "cached_branches", "cached_prs", "branch_visibility",
"branch_contribute_grants", "stars", "threads", "changes", "pr_seen",
"branch_chat_seen", "watches", "notifications", "actions",
"pr_resolution_branches", "funder_consents", "rfc_invitations",
"rfc_collaborators", "proposed_use_cases", "contribution_requests",
# §22 three-tier (migration 029): the entry-corpus grain is the collection, so
# the 13 tables migration 028 keyed by project_id re-key to collection_id. The
# remaining tables 026 tagged keep their denormalised project_id (project grain).
COLLECTION_TABLES = [
"cached_rfcs", "cached_branches", "branch_visibility",
"branch_contribute_grants", "stars", "pr_seen", "branch_chat_seen",
"watches", "funder_consents", "rfc_invitations", "rfc_collaborators",
"proposed_use_cases", "contribution_requests",
]
PROJECT_TAG_TABLES = [
"cached_prs", "threads", "changes", "notifications", "actions",
"pr_resolution_branches",
]
@@ -45,25 +49,28 @@ def test_default_project_seeded_and_backfilled(app_with_fake_gitea):
assert row["content_repo"] == "meta"
def test_project_id_on_every_slug_table(app_with_fake_gitea):
def test_grain_columns_on_every_slug_table(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
for table in SLUG_TABLES:
cols = {r["name"]: r for r in db.conn().execute(
f"PRAGMA table_info({table})"
)}
assert "project_id" in cols, f"{table} missing project_id"
col = cols["project_id"]
# NOT NULL with the constant 'default' backfill default.
assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL"
assert col["dflt_value"] == "'default'", f"{table}.project_id default"
# The entry-corpus tables key on collection_id (NOT NULL, 'default').
for table in COLLECTION_TABLES:
cols = {r["name"]: r for r in db.conn().execute(f"PRAGMA table_info({table})")}
assert "collection_id" in cols, f"{table} missing collection_id"
assert "project_id" not in cols, f"{table} should no longer have project_id"
col = cols["collection_id"]
assert col["notnull"] == 1, f"{table}.collection_id should be NOT NULL"
assert col["dflt_value"] == "'default'", f"{table}.collection_id default"
# The project-tag tables keep their denormalised project_id.
for table in PROJECT_TAG_TABLES:
cols = {r["name"] for r in db.conn().execute(f"PRAGMA table_info({table})")}
assert "project_id" in cols, f"{table} missing project_id tag"
def test_existing_row_backfills_to_default(app_with_fake_gitea):
"""A row inserted the old way (no project_id) lands in the default
project the trick that keeps every pre-multi-project INSERT working."""
"""A row inserted the old way (no collection grain) lands in the default
collection the trick that keeps every pre-three-tier INSERT working."""
from app import db
app, _ = app_with_fake_gitea
@@ -73,35 +80,36 @@ def test_existing_row_backfills_to_default(app_with_fake_gitea):
("human", "Human", "active"),
)
got = db.conn().execute(
"SELECT project_id FROM cached_rfcs WHERE slug = 'human'"
).fetchone()["project_id"]
"SELECT collection_id FROM cached_rfcs WHERE slug = 'human'"
).fetchone()["collection_id"]
assert got == "default"
def test_project_members_table_shape(app_with_fake_gitea):
def test_memberships_table_shape(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
cols = {r["name"] for r in db.conn().execute(
"PRAGMA table_info(project_members)"
)}
assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"}
# The role CHECK rejects an unknown role.
# §22 three-tier: project_members generalised into memberships.
assert db.conn().execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='project_members'"
).fetchone() is None
cols = {r["name"] for r in db.conn().execute("PRAGMA table_info(memberships)")}
assert {"scope_type", "scope_id", "user_id", "role", "granted_by", "granted_at"} <= cols
db.conn().execute(
"INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')"
)
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) "
"VALUES ('default', 1, 'project_admin')"
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('collection', 'default', 1, 'owner')"
)
import sqlite3
try:
db.conn().execute(
"INSERT INTO project_members (project_id, user_id, role) "
"VALUES ('default', 1, 'nonsense')"
"INSERT INTO memberships (scope_type, scope_id, user_id, role) "
"VALUES ('collection', 'default', 1, 'nonsense')"
)
assert False, "CHECK should reject an unknown project role"
assert False, "CHECK should reject an unknown role"
except sqlite3.IntegrityError:
pass
@@ -0,0 +1,72 @@
"""§22.4 (Plan B write): proposing a new entry into a *specific* project lands
it in that project's content repo and surfaces under that project's proposals,
isolated from the default project."""
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 _register_ecomm(fake):
from app import db
db.conn().execute(
"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")
def test_propose_into_second_project_lands_scoped(app_with_fake_gitea):
app, fake = app_with_fake_gitea
with TestClient(app) as client:
_register_ecomm(fake)
provision_user_row(user_id=3, login="alice", role="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={
"title": "Cart", "slug": "cart", "pitch": "why a cart", "tags": [],
})
assert r.status_code == 200, r.text
# The idea PR shows under ecomm's proposals, not the default's.
e = {i["slug"] for i in client.get("/api/projects/ecomm/proposals").json()["items"]}
d = {i["slug"] for i in client.get("/api/projects/default/proposals").json()["items"]}
assert "cart" in e
assert "cart" not in d
# It landed in ecomm's content repo, not the default 'meta' repo.
assert ("wiggleverse", "ecomm-content") in {
(o, rp) for (o, rp) in fake.branches if rp == "ecomm-content"
}
assert any(
br.startswith("propose/cart")
for br in fake.branches.get(("wiggleverse", "ecomm-content"), {})
)
def test_propose_into_gated_project_404s_for_non_member(app_with_fake_gitea):
app, _ = app_with_fake_gitea
from app import db
with TestClient(app) as client:
db.conn().execute(
"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",
role="contributor", email="bob@test")
r = client.post("/api/projects/secret/rfcs/propose", json={
"title": "X", "slug": "x", "pitch": "p", "tags": [],
})
assert r.status_code == 404
+10 -3
View File
@@ -11,18 +11,25 @@ from test_propose_vertical import ( # noqa: F401
def _add_project(pid, name, vis="public"):
# §22 three-tier: a project + its default collection (keyed by the project
# id in tests, so default_collection_id(pid) == pid).
from app import db
db.conn().execute(
"INSERT OR IGNORE INTO projects (id, name, type, content_repo, visibility, initial_state) "
"VALUES (?, ?, 'document', ?, ?, 'super-draft')",
"INSERT OR IGNORE INTO projects (id, name, content_repo, visibility) VALUES (?, ?, ?, ?)",
(pid, name, pid + "-content", vis),
)
db.conn().execute(
"INSERT OR IGNORE INTO collections (id, project_id, type, subfolder, initial_state, visibility, name) "
"VALUES (?, ?, 'document', '', 'super-draft', ?, ?)",
(pid, pid, vis, name),
)
def _add_rfc(slug, title, pid, state="active"):
from app import db
# entries key by the project's default collection (id == pid in these tests)
db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state, project_id) VALUES (?, ?, ?, ?)",
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
(slug, title, state, pid),
)
+14 -6
View File
@@ -74,13 +74,20 @@ def test_parse_rejects_invalid(bad, msg):
def test_apply_upserts_projects_and_deployment():
_db()
doc = registry.parse_registry(VALID)
registry.apply_registry(doc, registry_sha="regsha1")
registry.apply_registry(doc, registry_sha="regsha1", default_id="default")
# §22 three-tier: the project carries the grouping-tier fields; the
# per-corpus type/initial_state live on its default collection.
prow = db.conn().execute(
"SELECT name, type, content_repo, visibility, initial_state, registry_sha FROM projects WHERE id='default'"
"SELECT name, content_repo, visibility, registry_sha FROM projects WHERE id='default'"
).fetchone()
assert prow["name"] == "Open Human Model"
assert prow["content_repo"] == "meta"
assert prow["registry_sha"] == "regsha1"
crow = db.conn().execute(
"SELECT type, initial_state FROM collections WHERE id='default'"
).fetchone()
assert crow["type"] == "document"
assert crow["initial_state"] == "super-draft"
drow = db.conn().execute("SELECT name, tagline FROM deployment WHERE id=1").fetchone()
assert drow["name"] == "Open Human Model"
assert drow["tagline"] == "A model of human flourishing"
@@ -88,11 +95,12 @@ def test_apply_upserts_projects_and_deployment():
def test_apply_rejects_type_change_on_existing_project():
_db()
registry.apply_registry(registry.parse_registry(VALID), "s1")
registry.apply_registry(registry.parse_registry(VALID), "s1", default_id="default")
changed = VALID.replace("type: document", "type: specification")
registry.apply_registry(registry.parse_registry(changed), "s2") # logged + skipped, no raise
t = db.conn().execute("SELECT type FROM projects WHERE id='default'").fetchone()["type"]
registry.apply_registry(registry.parse_registry(changed), "s2", default_id="default") # skipped
# §22.4a immutable type — now enforced on the collection.
t = db.conn().execute("SELECT type FROM collections WHERE id='default'").fetchone()["type"]
assert t == "document" # immutable — unchanged
# The deployment row IS still advanced even though the project upsert was skipped.
# The deployment row IS still advanced even though the type change was skipped.
drow = db.conn().execute("SELECT registry_sha FROM deployment WHERE id=1").fetchone()
assert drow["registry_sha"] == "s2"
+6 -2
View File
@@ -12,10 +12,14 @@ def test_startup_mirrors_registry_into_projects_and_deployment(app_with_fake_git
app, _ = app_with_fake_gitea
with TestClient(app):
prow = db.conn().execute(
"SELECT content_repo, type, initial_state FROM projects WHERE id='default'"
"SELECT content_repo FROM projects WHERE id='default'"
).fetchone()
assert prow["content_repo"] == "meta" # from the registry, not META_REPO
assert prow["type"] == "document"
# §22 three-tier: type now lives on the default collection.
crow = db.conn().execute(
"SELECT type FROM collections WHERE id='default'"
).fetchone()
assert crow["type"] == "document"
drow = db.conn().execute("SELECT name FROM deployment WHERE id=1").fetchone()
assert drow["name"] # deployment name mirrored from the registry
@@ -0,0 +1,69 @@
"""§22.13 step 1 — the bootstrap-id re-stamp: 'default' → the configured
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
from pathlib import Path
import app.db as db
from app import projects
class _Cfg:
def __init__(self, path, default_id):
self.database_path = path
self.default_project_id = default_id
def _setup(monkeypatch, default_id="ohm"):
path = str(Path(tempfile.mkdtemp()) / "t.db")
cfg = _Cfg(path, default_id)
db.run_migrations(cfg) # seeds the bootstrap 'default' project + its default collection
monkeypatch.setattr(db, "_CONN", db.connect(path))
conn = db.conn()
# 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')")
# 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,collection_id) VALUES (1,'human','default')")
return cfg, conn
def test_restamp_moves_project_grain_and_drops_bootstrap_row(monkeypatch):
cfg, conn = _setup(monkeypatch, default_id="ohm")
projects.restamp_default_project(cfg)
# 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
# FK integrity intact after the rename
assert conn.execute("PRAGMA foreign_key_check").fetchall() == []
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 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; the default collection still belongs to the bootstrap project
assert conn.execute("SELECT project_id FROM collections WHERE id='default'").fetchone()["project_id"] == "default"
@@ -0,0 +1,78 @@
"""@S1 acceptance — the collection grain exists (invisible default) and N=1 is
unchanged.
Part C scenarios C3.7 (single-collection project skips the directory) and C3.8
(single-project deployment skips the directory) are the client-side redirect
contract asserted in the frontend; this module asserts the backend N=1
invariants behind the slice: every entry keys on a real collection_id, the
shipped project-scoped serving still resolves through the default collection,
and the legacy /rfc/<slug> URL 308-redirects through /c/<default>/.
Binding: docs/design/2026-06-05-three-tier-projects-collections.md §A.6 / Part E.
"""
from __future__ import annotations
from fastapi.testclient import TestClient
from test_propose_vertical import ( # noqa: F401
app_with_fake_gitea, tmp_env, provision_user_row, sign_in_as,
)
def _seed_entry(slug, title, collection_id="default", state="active"):
from app import db
db.conn().execute(
"INSERT INTO cached_rfcs (slug, title, state, collection_id) VALUES (?, ?, ?, ?)",
(slug, title, state, collection_id),
)
def test_s1_migration_seeds_one_default_collection_for_the_default_project(app_with_fake_gitea):
from app import db
app, _ = app_with_fake_gitea
with TestClient(app):
row = db.conn().execute(
"SELECT id FROM collections WHERE project_id = 'default'"
).fetchall()
assert len(row) == 1
assert row[0]["id"] == "default"
def test_s1_entry_served_under_default_collection(app_with_fake_gitea):
"""N=1 unchanged: an entry is keyed by collection_id under the hood and the
shipped project-scoped serving endpoint still resolves it."""
from app import db
app, _ = app_with_fake_gitea
with TestClient(app) as client:
_seed_entry("human", "Human")
# the row carries a real collection grain (the default collection)
cid = db.conn().execute(
"SELECT collection_id FROM cached_rfcs WHERE slug='human'"
).fetchone()["collection_id"]
assert cid == "default"
# project-scoped serving (collection = default) still returns it
r = client.get("/api/projects/default/rfcs/human")
assert r.status_code == 200, r.text
assert r.json()["slug"] == "human"
# and it appears in the project catalog
slugs = [i["slug"] for i in client.get("/api/projects/default/rfcs").json()["items"]]
assert "human" in slugs
def test_s1_legacy_rfc_url_redirects_through_collection(app_with_fake_gitea):
"""The shipped /rfc/<slug> now 308s through the default collection segment."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
r = client.get("/rfc/human", follow_redirects=False)
assert r.status_code == 308
assert r.headers["location"] == "/p/default/c/default/e/human"
def test_s1_deployment_reports_single_project(app_with_fake_gitea):
"""C3.8 precondition: the N=1 deployment reports exactly one visible project
and its default id (the frontend uses this to skip the directory)."""
app, _ = app_with_fake_gitea
with TestClient(app) as client:
body = client.get("/api/deployment").json()
assert body["default_project_id"] == "default"
assert [p["id"] for p in body["projects"]] == ["default"]
@@ -0,0 +1,637 @@
# 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 AD) 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):** M1M2, 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.** 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.** The four-layer most-permissive resolver
(§B.2) over `{owner, contributor}` grants at `{global, project, collection}`,
with grants applied administratively (DB / admin endpoint); every write gate
re-checked under the collection axis. **Usable end-state:** a user granted
RFC Contributor at a scope can contribute across exactly that subtree, and
Owners administer their subtree. **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.3C3.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.1C3.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.1C1.8 (`@S3`) |
| **S4** | invitation UI + role-aware empty states | C2.1C2.7, C3.3C3.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).
File diff suppressed because it is too large Load Diff
@@ -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 15 → 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 48.
- **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.
```
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.37.0",
"version": "0.40.0",
"type": "module",
"scripts": {
"dev": "vite",
+46 -7
View File
@@ -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'
@@ -61,6 +61,11 @@ export default function App() {
// §22.9 runtime deployment config (name for the brand, default project id
// for building corpus links this slice; see DeploymentProvider).
const deployment = useDeployment()
// §22.4 the project the viewer is currently in (from the /p/<id>/ URL),
// so the propose modal (App-level chrome, above the route tree) targets the
// 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
// #28 Parts 23: 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;
@@ -355,10 +360,21 @@ 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 three-tier (C3.7): the project landing redirects into
its sole/default collection (S1: the `default` one). */}
<Route path="" element={<DefaultCollectionRedirect />} />
{/* 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>
@@ -372,12 +388,13 @@ export default function App() {
<ProposeModal
viewer={viewer}
initialTitle={proposeParam || ''}
projectId={currentProjectId}
onClose={() => { setProposeOpen(false); clearParams('propose') }}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
clearParams('propose')
setCatalogVersion(v => v + 1)
navigate(proposalPath(deployment.defaultProjectId, pr_number))
navigate(proposalPath(currentProjectId, pr_number))
}}
/>
)}
@@ -397,6 +414,28 @@ export default function App() {
)
}
// §22 three-tier corpus redirects mounted under /p/:projectId/*.
// C3.7: the project landing skips the (single-collection) directory and lands in
// the project's default collection.
function DefaultCollectionRedirect() {
const { projectId } = useParams()
return <Navigate to={`/p/${projectId}/c/${DEFAULT_COLLECTION}/`} replace />
}
// 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
+8 -4
View File
@@ -198,16 +198,20 @@ export async function getRFC(projectId, slug) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
}
export async function listProposals() {
return jsonOrThrow(await fetch('/api/proposals'))
export async function listProposals(projectId) {
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
return jsonOrThrow(await fetch(url))
}
export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
const res = await fetch('/api/rfcs/propose', {
// §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'
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// #26: proposed_use_case is optional; send null when blank so the
+1 -1
View File
@@ -35,7 +35,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
useEffect(() => {
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
}, [version, pid])
const filtered = useMemo(() => {
+2 -2
View File
@@ -28,7 +28,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) {
// #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.
@@ -92,7 +92,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
setSubmitting(true)
setError(null)
try {
const result = await proposeRFC({
const result = await proposeRFC(projectId, {
title: title.trim(),
slug,
pitch: pitch.trim(),
+19 -12
View File
@@ -1,22 +1,29 @@
// §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 { 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) {
+36
View File
@@ -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')
})
})