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..2b858a9 --- /dev/null +++ b/docs/design/2026-06-05-three-tier-projects-collections.md @@ -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//` 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) 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). + +--- + +## 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//e/` 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//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 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). + +--- + +# 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` 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 + +```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//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, 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)`. + +**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 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//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). + +- **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). + +- **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.3–C3.5). + +- **S5 — In-app create-project + the global directory.** The global-Owner + **create-project** action (bot provisions a Gitea content repo + commits to + `projects.yaml`); the deployment directory empty states. **Usable end-state:** + a global Owner stands up a new project end-to-end from the UI. **Completes:** + `@S5` (the global-directory empty states C3.1–C3.2). + +- **S6 — Type modules, membership lifecycle, hardening, SPEC merge.** Per-type + frontmatter + surfaces selected on the **collection's** `type`; request-to-join + + cross-collection inbox; per-collection `enabled_models`; the registry + + manifest format in `docs/DEPLOYMENTS.md`; two-project / multi-collection e2e; + the §20.4 changelog + upgrade-steps; the SPEC merge (Part A applied, Part D in + place). **Usable end-state:** the model is fully realized and merged into + `SPEC.md`. **Completes:** type-specific scenarios (added in S6, beyond Part C's + role focus). + +### Slice → scenario index (the inverse of the `@S` 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) + +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//e/` → + `/p//c//e/`, and the pre-multi-project `/rfc/` + / `/proposals/` → their `/p//c//…` 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).