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).