From 31d680be54c1ff12c0079a3d93dbc1dc6da07347 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 03:39:04 -0700 Subject: [PATCH 1/4] =?UTF-8?q?=C2=A722=20three-tier=20refactor=20spec:=20?= =?UTF-8?q?project=20=E2=86=92=20RFC=20collection=20+=20unified=20roles=20?= =?UTF-8?q?+=20BDDs?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- ...6-06-05-three-tier-projects-collections.md | 543 ++++++++++++++++++ 1 file changed, 543 insertions(+) create mode 100644 docs/design/2026-06-05-three-tier-projects-collections.md diff --git a/docs/design/2026-06-05-three-tier-projects-collections.md b/docs/design/2026-06-05-three-tier-projects-collections.md new file mode 100644 index 0000000..e4d0fce --- /dev/null +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -0,0 +1,543 @@ +# 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. +> Target release: folded into the not-yet-shipped §22 work (Plan B + M3-frontend), +> a pre-1.0 minor carrying breaking changes with 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 timing is favorable: the breaking slug-PK rebuild (Plan B / migration 028) +and the public `/p//` routing (M3-frontend) **have not shipped**, so this is +a revision of in-flight *designs*, not a rework of shipped surfaces (§7). + +--- + +# 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//c//e/ +``` + +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//c//…`. 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//` is the project landing: a **directory of +collections** in that project the visitor can see. Conveniences: + +- `/p//` 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 light: no `/p//` URL ever shipped (M3-frontend is unmerged). +The only live legacy URLs are the pre-multi-project `/rfc/` / +`/proposals/`, which **308-redirect** to the migration's default project → +default collection (§A.6). + +--- + +# 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. + +## 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) + + 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 + + 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 + + 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" + + 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" + + 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" + + 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" + + 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" + + 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 + + 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" + + 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" + + 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 + + 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) + + 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 + + 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 + + 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 + + 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 + + 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 + + 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 + + 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 + + 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 + + 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 + + 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/" + + 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//c//…`. `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 decision): **fold the third tier into the not-yet-shipped +work** rather than ship two-tier and migrate again. M1, M2, and M3-backend +Plan A are additive and already merged (v0.39.0); they keep running. The tier +goes in at the **first breaking migration (Plan B / 028)** and the **first +public routing (M3-frontend)**, before either ships. + +- **Landed, unchanged (v0.39.0):** M1 (additive `project_id` spine, mig 026), + M2 (authz resolver), M3-backend Plan A (registry mirror, APIs, propose / + mark-reviewed, mig 027). These read fine under the new model: today's single + `projects` row is re-read as the **default collection** after the §E migration + relabels it. + +- **M3-backend Plan B → "insert the project tier + re-key to per-collection"** + (revised). Migration 028 now: (1) add the `projects` (grouping) table with + `content_repo`; (2) rename the landed `projects` table to `collections`, drop + its `content_repo`, add `project_id` + `subfolder`; (3) re-key every + entry-scoped `project_id` → `collection_id`; (4) the slug-PK rebuild targets + `(collection_id, slug)`; (5) generalize `project_members` → the polymorphic + `memberships` table and collapse the role enum (§B.3); (6) the §22.13 default + project wraps the default collection (§A.6). One breaking migration, one slug + rebuild. + +- **M3-backend Plan B+ — manifests & in-app create.** Teach the mirror to read + `.collection.yaml`; add the bot-commit-wrapped **create-project** and + **create-collection** endpoints; mirror new collections. + +- **M3-frontend → three-tier routing.** `/p//c//e/`; + the deployment directory at `/`, the project (collection) directory at + `/p//`; the single-project / single-collection redirects (§A.3); the + 308s off `/rfc/`; runtime branding at both project and collection. + +- **M4 — second-collection + second-project acceptance.** End-to-end pass: a + second collection in a project, and a second project, each with disjoint + membership, full propose→graduate lifecycle under the per-collection slug + namespace. + +- **M5 — type modules (unchanged in intent).** Per-type frontmatter + surfaces, + now selected on the **collection's** `type`. + +- **M6 — membership lifecycle.** Invite + request-to-join at all three scopes + (Part C.2); the cross-collection inbox; the roster admin surface. The Part C + scenarios are this slice's acceptance tests. + +- **M7 — hardening + SPEC merge.** 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 + amendments in place). + +## E.1 (= §A.6) Migration — the default project and default collection + +A pre-three-tier deployment (already on v0.39.0's single `projects` row, or +pre-multi-project) is migrated so it keeps running: + +1. The landed single `projects` row becomes the **default collection** + (`type=document`, its `initial_state`/`unreviewed` preserved). +2. A **default project** is generated to wrap it: `content_repo` moves from the + (old project / now collection) onto the project; `id` is the config-derived + slug already re-stamped in §22.13 step 1 (v0.39.0). +3. Every entry-scoped `project_id` is re-keyed to the default `collection_id`. +4. M2's `project_members` rows migrate to `memberships(scope_type='collection')` + on the default collection, role-collapsed (§B.3). +5. `/rfc/` and `/proposals/` 308-redirect to + `/p//c//…`. + +Until a second project or collection is added, the deployment is functionally +identical to before, with two extra path segments. 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). From 9c0e3b60ac0cad7d2e7009640bc28d62dd914ebf Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 03:43:44 -0700 Subject: [PATCH 2/4] =?UTF-8?q?Correct=20=C2=A722=20three-tier=20spec:=20t?= =?UTF-8?q?wo-tier=20model=20already=20shipped=20(v0.39.0)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Re-checked code vs the stale memory: migration 028 (slug PK -> (project_id, slug)), v0.35.0 /p// 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) --- ...6-06-05-three-tier-projects-collections.md | 52 ++++++++++++++----- 1 file changed, 38 insertions(+), 14 deletions(-) diff --git a/docs/design/2026-06-05-three-tier-projects-collections.md b/docs/design/2026-06-05-three-tier-projects-collections.md index e4d0fce..b71d70a 100644 --- a/docs/design/2026-06-05-three-tier-projects-collections.md +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -10,8 +10,18 @@ > 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. -> Target release: folded into the not-yet-shipped §22 work (Plan B + M3-frontend), -> a pre-1.0 minor carrying breaking changes with upgrade steps (§20.2). +> +> ⚠️ **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//` routing and +> the live `/p//e/` 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) is **pending re-decision** with correct facts; the structural model +> (Parts A–D) is unaffected. Target release: a further pre-1.0 minor with +> breaking changes + upgrade steps (§20.2). --- @@ -29,9 +39,11 @@ 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 timing is favorable: the breaking slug-PK rebuild (Plan B / migration 028) -and the public `/p//` routing (M3-frontend) **have not shipped**, so this is -a revision of in-flight *designs*, not a rework of shipped surfaces (§7). +⚠️ The two-tier model is **already shipped** (v0.39.0): migration 028 rebuilt +the slug PK to `(project_id, slug)`, and `/p//e/` URLs are live +(v0.35.0). So inserting the third tier evolves a shipped system — see the +corrected Part E for the real migration cost (a new migration 029 + a breaking +URL change with 308s), pending re-decision. --- @@ -166,10 +178,12 @@ collections** in that project the visitor can see. Conveniences: - `/` redirects to the sole visible project when there is exactly one (the N=1 case, §A.6). -Backcompat is light: no `/p//` URL ever shipped (M3-frontend is unmerged). -The only live legacy URLs are the pre-multi-project `/rfc/` / -`/proposals/`, which **308-redirect** to the migration's default project → -default collection (§A.6). +⚠️ **Backcompat is heavier than first drafted.** `/p//e/` URLs +**are live** (v0.35.0), so adding the `/c//` segment is a breaking +URL change: the shipped `/p//e/` must **308-redirect** to +`/p//c//e/`, alongside the pre-multi-project +`/rfc/` → `/p//c//…` redirect. Both +are handled in the migration (§A.6 / Part E). --- @@ -465,11 +479,21 @@ Applied in place when §22 is rewritten; listed here as the change surface. # Part E — Revised slicing plan (the roadmap re-slot) -Strategy (session 0072 decision): **fold the third tier into the not-yet-shipped -work** rather than ship two-tier and migrate again. M1, M2, and M3-backend -Plan A are additive and already merged (v0.39.0); they keep running. The tier -goes in at the **first breaking migration (Plan B / 028)** and the **first -public routing (M3-frontend)**, before either ships. +> ⚠️ **PENDING RE-DECISION (session 0072).** The strategy below assumed Plan B +> and M3-frontend were unshipped. They are shipped (v0.35.0–0.39.0). The "fold +> into not-yet-shipped work" framing is void; the actual work is a **new +> migration 029** that evolves the shipped two-tier schema to three tiers, plus +> a **breaking `/p//e/` → `/p//c//e/` +> URL change with 308s**. The two live mapping options (relabel today's +> project → collection + insert a group tier above, vs. add a collection +> sub-grain beneath today's project) are being put back to the operator. The +> text below is retained only as the structural target, not the sequencing. + +Strategy (ORIGINAL, premise now false): fold the third tier into the +not-yet-shipped work rather than ship two-tier and migrate again. M1, M2, and +M3-backend Plan A are additive and already merged (v0.39.0); they keep running. +The tier goes in at the **first breaking migration (Plan B / 028)** and the +**first public routing (M3-frontend)**, before either ships. - **Landed, unchanged (v0.39.0):** M1 (additive `project_id` spine, mig 026), M2 (authz resolver), M3-backend Plan A (registry mirror, APIs, propose / From 87279fc54510e6ded3f302ced548c6b0e9c8f1fc Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 03:46:51 -0700 Subject: [PATCH 3/4] =?UTF-8?q?=C2=A722=20three-tier=20spec=20Part=20E:=20?= =?UTF-8?q?decided=20migration-029=20strategy=20(collection=20grain=20bene?= =?UTF-8?q?ath=20project)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//e/ -> /p//c//e/ with 308s. Re-sloted slices N1-N6. Correction banners updated from pending -> decided. Co-Authored-By: Claude Opus 4.8 (1M context) --- ...6-06-05-three-tier-projects-collections.md | 154 ++++++++++-------- 1 file changed, 82 insertions(+), 72 deletions(-) diff --git a/docs/design/2026-06-05-three-tier-projects-collections.md b/docs/design/2026-06-05-three-tier-projects-collections.md index b71d70a..aebdd7d 100644 --- a/docs/design/2026-06-05-three-tier-projects-collections.md +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -19,9 +19,11 @@ > the live `/p//e/` 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) is **pending re-decision** with correct facts; the structural model -> (Parts A–D) is unaffected. Target release: a further pre-1.0 minor with -> breaking changes + upgrade steps (§20.2). +> (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//e/` → `/p//c//e/` URL +> change with 308s. The structural model (Parts A–D) is unaffected. Target +> release: a further pre-1.0 minor with breaking changes + upgrade steps (§20.2). --- @@ -41,9 +43,9 @@ 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//e/` URLs are live -(v0.35.0). So inserting the third tier evolves a shipped system — see the -corrected Part E for the real migration cost (a new migration 029 + a breaking -URL change with 308s), pending re-decision. +(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). --- @@ -479,83 +481,91 @@ Applied in place when §22 is rewritten; listed here as the change surface. # Part E — Revised slicing plan (the roadmap re-slot) -> ⚠️ **PENDING RE-DECISION (session 0072).** The strategy below assumed Plan B -> and M3-frontend were unshipped. They are shipped (v0.35.0–0.39.0). The "fold -> into not-yet-shipped work" framing is void; the actual work is a **new -> migration 029** that evolves the shipped two-tier schema to three tiers, plus -> a **breaking `/p//e/` → `/p//c//e/` -> URL change with 308s**. The two live mapping options (relabel today's -> project → collection + insert a group tier above, vs. add a collection -> sub-grain beneath today's project) are being put back to the operator. The -> text below is retained only as the structural target, not the sequencing. +**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//` routing + live `/p//e/` 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)`. The migrated default keeps every deployment +running; the new `/c//` URL segment is added with 308s off the +now-legacy two-tier URLs. -Strategy (ORIGINAL, premise now false): fold the third tier into the -not-yet-shipped work rather than ship two-tier and migrate again. M1, M2, and -M3-backend Plan A are additive and already merged (v0.39.0); they keep running. -The tier goes in at the **first breaking migration (Plan B / 028)** and the -**first public routing (M3-frontend)**, before either ships. +- **Landed, unchanged (v0.39.0):** M1–M2, M3-backend Plan A **and** Plan B + (read+propose, mig 028), M3-frontend (`/p//` routing), §22.13 + re-stamp. None of this is rebuilt; it is *evolved* by migration 029 below. -- **Landed, unchanged (v0.39.0):** M1 (additive `project_id` spine, mig 026), - M2 (authz resolver), M3-backend Plan A (registry mirror, APIs, propose / - mark-reviewed, mig 027). These read fine under the new model: today's single - `projects` row is re-read as the **default collection** after the §E migration - relabels it. +- **N1 — migration 029: add the collection grain.** A new + `029_collections.sql` that: (1) **adds a `collections` table** — + `(id, project_id, type, subfolder, initial_state, visibility, name, + registry_sha)`; (2) **moves the per-corpus fields** (`type`, `initial_state`, + visibility) from the shipped `projects` table **down** to `collections`, + leaving `projects` as `(id, content_repo, visibility, name, tagline, theme, + enabled_models, …)`; (3) **creates one default collection per existing + project** (id `default`, `subfolder` = repo root, inheriting that project's + `type`/`initial_state`); (4) **re-keys every entry-scoped table** from + `(project_id, slug)` to `(collection_id, slug)` via the same SQLite + rebuild procedure migration 028 used (the `028_project_scoped_keys.sql` + pattern — `__new` table, copy, drop, rename, FK-off + `foreign_key_check`), + stamping the default `collection_id`; (5) **generalizes `project_members` → + `memberships(scope_type ∈ {project, collection}, scope_id, …)`**, migrating + existing rows to `scope_type='collection'` on the default collection and + collapsing the role enum to `{owner, contributor}` (§B.3). -- **M3-backend Plan B → "insert the project tier + re-key to per-collection"** - (revised). Migration 028 now: (1) add the `projects` (grouping) table with - `content_repo`; (2) rename the landed `projects` table to `collections`, drop - its `content_repo`, add `project_id` + `subfolder`; (3) re-key every - entry-scoped `project_id` → `collection_id`; (4) the slug-PK rebuild targets - `(collection_id, slug)`; (5) generalize `project_members` → the polymorphic - `memberships` table and collapse the role enum (§B.3); (6) the §22.13 default - project wraps the default collection (§A.6). One breaking migration, one slug - rebuild. +- **N2 — backend collection scoping.** Thread `collection_id` through the + resolution gates that migration 028/Plan B threaded `project_id` through + (`app/auth.py`, `app/projects.py`, `app/cache.py`, the `api_*` writers); + generalize the §22.7 union to the four-layer resolver (§B.2); add the + `GET /api/projects/:id/collections` and + `GET /api/projects/:id/collections/:cid` surfaces; scope propose/serve/PR + endpoints to `(project, collection)`. -- **M3-backend Plan B+ — manifests & in-app create.** Teach the mirror to read - `.collection.yaml`; add the bot-commit-wrapped **create-project** and - **create-collection** endpoints; mirror new collections. +- **N3 — manifests & in-app create.** Teach the registry mirror to read + `.collection.yaml` from each project's content repo; add the + bot-commit-wrapped **create-collection** (project Owner / RFC Contributor) + and **create-project** (global Owner) endpoints (§A.2); mirror new + collections into `collections` rows. -- **M3-frontend → three-tier routing.** `/p//c//e/`; - the deployment directory at `/`, the project (collection) directory at - `/p//`; the single-project / single-collection redirects (§A.3); the - 308s off `/rfc/`; runtime branding at both project and collection. +- **N4 — frontend three-tier routing.** Add the `/c//` segment: + `/p//c//e/`; the project (collection) directory at + `/p//`; the single-collection redirect (§A.3); **308** the shipped + `/p//e/` → `/p//c//e/`; per-collection + chrome layered under the existing per-project chrome. -- **M4 — second-collection + second-project acceptance.** End-to-end pass: a - second collection in a project, and a second project, each with disjoint - membership, full propose→graduate lifecycle under the per-collection slug - namespace. +- **N5 — roles surface (this pass's scope).** The `{owner, contributor}` × + `{global, project, collection}` grants (Part B) with the invite surfaces and + empty states of Part C as acceptance tests. (Richer roles deferred, §B.1.) -- **M5 — type modules (unchanged in intent).** Per-type frontmatter + surfaces, - now selected on the **collection's** `type`. +- **N6 — type modules / membership lifecycle / hardening.** Carries the original + §22 M5–M7 forward, now selecting on the **collection's** `type`: per-type + frontmatter + surfaces; 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 amendments in place). -- **M6 — membership lifecycle.** Invite + request-to-join at all three scopes - (Part C.2); the cross-collection inbox; the roster admin surface. The Part C - scenarios are this slice's acceptance tests. +## E.1 (= §A.6) Migration — the default collection (the N=1 case) -- **M7 — hardening + SPEC merge.** 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 - amendments in place). +A deployment on the shipped two-tier schema (v0.39.0) is migrated by 029 so it +keeps running unchanged: -## E.1 (= §A.6) Migration — the default project and default collection +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//e/` → + `/p//c//e/`, and the pre-multi-project `/rfc/` + / `/proposals/` → their `/p//c//…` equivalents. -A pre-three-tier deployment (already on v0.39.0's single `projects` row, or -pre-multi-project) is migrated so it keeps running: - -1. The landed single `projects` row becomes the **default collection** - (`type=document`, its `initial_state`/`unreviewed` preserved). -2. A **default project** is generated to wrap it: `content_repo` moves from the - (old project / now collection) onto the project; `id` is the config-derived - slug already re-stamped in §22.13 step 1 (v0.39.0). -3. Every entry-scoped `project_id` is re-keyed to the default `collection_id`. -4. M2's `project_members` rows migrate to `memberships(scope_type='collection')` - on the default collection, role-collapsed (§B.3). -5. `/rfc/` and `/proposals/` 308-redirect to - `/p//c//…`. - -Until a second project or collection is added, the deployment is functionally -identical to before, with two extra path segments. This is the §20.4 -upgrade-steps content for the release. +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 From 2f5d09aef5f9f0001c3309c11ad5cecb91e7e296 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Fri, 5 Jun 2026 06:42:12 -0700 Subject: [PATCH 4/4] =?UTF-8?q?=C2=A722=20spec:=20re-cut=20Part=20E=20into?= =?UTF-8?q?=20usable=20BDD-tagged=20slices=20(S1-S6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 (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) --- ...6-06-05-three-tier-projects-collections.md | 150 ++++++++++++------ 1 file changed, 105 insertions(+), 45 deletions(-) diff --git a/docs/design/2026-06-05-three-tier-projects-collections.md b/docs/design/2026-06-05-three-tier-projects-collections.md index aebdd7d..2b858a9 100644 --- a/docs/design/2026-06-05-three-tier-projects-collections.md +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -275,6 +275,12 @@ entry, not a subtree) and stays distinct. > *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` tag** naming the **slice** (Part E) that +> makes it pass — the "which scenarios are done after this slice" marker. After +> shipping slice S, its acceptance gate is "every `@S` 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 @@ -288,6 +294,7 @@ Feature: Scope roles grant authority over a subtree 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" @@ -295,39 +302,46 @@ Feature: Scope roles grant authority over a subtree 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 @@ -343,6 +357,7 @@ Feature: Inviting users to a scope role 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" @@ -350,33 +365,39 @@ Feature: Inviting users to a scope role 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" @@ -391,12 +412,14 @@ Feature: Empty states at each tier 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 @@ -404,6 +427,7 @@ Feature: Empty states at each tier 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" @@ -411,28 +435,33 @@ Feature: Empty states at each tier 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 "/" @@ -489,61 +518,92 @@ 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)`. The migrated default keeps every deployment -running; the new `/c//` URL segment is added with 308s off the -now-legacy two-tier URLs. +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` 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` scenarios are green. - **Landed, unchanged (v0.39.0):** M1–M2, M3-backend Plan A **and** Plan B (read+propose, mig 028), M3-frontend (`/p//` routing), §22.13 - re-stamp. None of this is rebuilt; it is *evolved* by migration 029 below. + re-stamp. None of this is rebuilt; it is *evolved* by the slices below. -- **N1 — migration 029: add the collection grain.** A new - `029_collections.sql` that: (1) **adds a `collections` table** — +- **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) **moves the per-corpus fields** (`type`, `initial_state`, - visibility) from the shipped `projects` table **down** to `collections`, - leaving `projects` as `(id, content_repo, visibility, name, tagline, theme, - enabled_models, …)`; (3) **creates one default collection per existing - project** (id `default`, `subfolder` = repo root, inheriting that project's - `type`/`initial_state`); (4) **re-keys every entry-scoped table** from - `(project_id, slug)` to `(collection_id, slug)` via the same SQLite - rebuild procedure migration 028 used (the `028_project_scoped_keys.sql` - pattern — `__new` table, copy, drop, rename, FK-off + `foreign_key_check`), - stamping the default `collection_id`; (5) **generalizes `project_members` → - `memberships(scope_type ∈ {project, collection}, scope_id, …)`**, migrating - existing rows to `scope_type='collection'` on the default collection and - collapsing the role enum to `{owner, contributor}` (§B.3). + 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//e/` → + `/p//c//e/`. **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). -- **N2 — backend collection scoping.** Thread `collection_id` through the - resolution gates that migration 028/Plan B threaded `project_id` through - (`app/auth.py`, `app/projects.py`, `app/cache.py`, the `api_*` writers); - generalize the §22.7 union to the four-layer resolver (§B.2); add the - `GET /api/projects/:id/collections` and - `GET /api/projects/:id/collections/:cid` surfaces; scope propose/serve/PR - endpoints to `(project, collection)`. +- **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//`; + collection-scoped propose/serve under `/p//c//`. + **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). -- **N3 — manifests & in-app create.** Teach the registry mirror to read - `.collection.yaml` from each project's content repo; add the - bot-commit-wrapped **create-collection** (project Owner / RFC Contributor) - and **create-project** (global Owner) endpoints (§A.2); mirror new - collections into `collections` rows. +- **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). -- **N4 — frontend three-tier routing.** Add the `/c//` segment: - `/p//c//e/`; the project (collection) directory at - `/p//`; the single-collection redirect (§A.3); **308** the shipped - `/p//e/` → `/p//c//e/`; per-collection - chrome layered under the existing per-project chrome. +- **S4 — Invitation surfaces + role-aware empty states.** The invite UI + (Owner-only) granting Owner/RFC Contributor at a scope or any scope beneath it, + with §15 notifications and the broader-scope-supersedes rule; the + create-first-collection / propose-first empty states keyed to the actor's role. + **Usable end-state:** an Owner invites collaborators at the right scope from + the UI. **Completes:** `@S4` (all of C.2 — invitation; plus the project/ + collection empty states C3.3–C3.5). -- **N5 — roles surface (this pass's scope).** The `{owner, contributor}` × - `{global, project, collection}` grants (Part B) with the invite surfaces and - empty states of Part C as acceptance tests. (Richer roles deferred, §B.1.) +- **S5 — In-app create-project + the global directory.** The global-Owner + **create-project** action (bot provisions a Gitea content repo + commits to + `projects.yaml`); the deployment directory empty states. **Usable end-state:** + a global Owner stands up a new project end-to-end from the UI. **Completes:** + `@S5` (the global-directory empty states C3.1–C3.2). -- **N6 — type modules / membership lifecycle / hardening.** Carries the original - §22 M5–M7 forward, now selecting on the **collection's** `type`: per-type - frontmatter + surfaces; 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 amendments in place). +- **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` tags) + +| Slice | Usable thing it ships | Completes (`@S`) | +|---|---|---| +| **S1** | collection grain + default + redirects; N=1 unchanged | C3.7, C3.8 (`@S1`) | +| **S2** | create + navigate + propose a 2nd collection | C3.6 (`@S2`) | +| **S3** | scope-role enforcement across global/project/collection | C1.1–C1.8 (`@S3`) | +| **S4** | invitation UI + role-aware empty states | C2.1–C2.7, C3.3–C3.5 (`@S4`) | +| **S5** | in-app create-project + global directory | C3.1, C3.2 (`@S5`) | +| **S6** | type surfaces, lifecycle, hardening, SPEC merge | type-specific (new) | + +Each slice is a candidate single session: it lands a usable deployment and a +runnable acceptance gate (`--tags @S`). **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)