Files
rfc-app/docs/design/2026-06-05-three-tier-projects-collections.md
T
Ben Stull 281dd29e62 §22 S6 (last item): spec pass for per-type surfaces (§22.4a items 1 & 3)
The per-type frontmatter schemas (item 1) and type-specific surfaces (item 3:
specification release-planning; bdd scenario/coverage views) were flagged at
v0.45.0 as wanting a discovery/spec pass first (they lacked BDD scenarios in
Part C). This is that pass — a design doc specifying the schemas, the surfaces,
their data model / API / frontend shape, BDD-style acceptance scenarios, and a
three-slice delivery plan (S7a schemas, S7b releases, S7c bdd surfaces), all
additive and engine-preserving per §22.4a. Doc-only; no code, no version bump.

- docs/design/2026-06-06-per-type-surfaces.md — the new spec pass.
- three-tier design doc S6 bullet: forward-pointer + S6 shipped/spec'd status.

Leaves implementation to the S7a–S7c coding slices; S7a (schemas) is unblocked,
S7b/S7c carry open product questions for the operator/discovery to settle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-06 02:15:45 -07:00

673 lines
34 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Draft spec — §22 refactor: three tiers (deployment → project → RFC collection)
> Status: **draft for review.** Binding voice, but not yet merged into
> `SPEC.md`. This doc **revises the §22 model** in
> [`multi-project-spec.md`](./multi-project-spec.md) from two tiers
> (deployment → project, where a "project" *is* a corpus) to **three tiers**
> (deployment → project → RFC collection, where the *collection* is the
> corpus). It supersedes the conflicting parts of that draft; the parts it does
> not touch (the registry-is-git-truth stance, the cache mirror, visibility
> semantics, the `type`/`initial_state`/`unreviewed` machinery) carry over
> unchanged, re-homed onto the collection. Rationale and the decisions behind
> this live in [`multi-project.md`](./multi-project.md) and session 0072.
>
> ⚠️ **CORRECTION (session 0072, after code re-check).** Parts of §0/§A.3/§E
> were drafted on a stale-memory premise that "Plan B (migration 028) and
> M3-frontend have not shipped." **That is false.** As of v0.39.0 the entire
> **two-tier** model is shipped to `main`: migration 028 already rebuilt the
> slug PK to `(project_id, slug)`; v0.35.0 shipped `/p/<project>/` routing and
> the live `/p/<project>/e/<slug>` URLs; v0.37.0/0.38.0 shipped per-project
> read + propose. Inserting the third tier is therefore an **evolution of a
> shipped system**, not a revision of unshipped designs. The migration strategy
> (Part E) was **re-decided on these corrected facts** (session 0072): a new
> **migration 029** adds a *collection* grain *beneath* today's project, plus a
> breaking `/p/<project>/e/<slug>` → `/p/<project>/c/<collection>/e/<slug>` URL
> change with 308s. The structural model (Parts AD) is unaffected. Target
> release: a further pre-1.0 minor with breaking changes + upgrade steps (§20.2).
---
## 0. Why this revision
The original §22 (`multi-project-spec.md`) gave a deployment **N projects**,
where each project *was* a single typed corpus: one content repo, one `type`,
one slug namespace, one member roster. That conflates two responsibilities —
**organizational grouping** and **a typed body of entries** — into one noun.
This revision splits them. A **project** becomes a pure grouping tier (settings
+ one content repo) that holds **any number of RFC collections**; an **RFC
collection** is the typed corpus the original §22 called a "project." Everything
the original §22 said about a corpus (type, slug namespace, catalog, philosophy,
landing state, review flag, membership) moves down one level to the collection;
the deployment level is unchanged.
⚠️ The two-tier model is **already shipped** (v0.39.0): migration 028 rebuilt
the slug PK to `(project_id, slug)`, and `/p/<project>/e/<slug>` URLs are live
(v0.35.0). So inserting the third tier evolves a shipped system — see Part E
for the decided strategy (a new migration 029 adding a collection grain beneath
today's project, + a breaking URL change with 308s).
---
# Part A — The three-tier model
## A.1 The tiers
```
deployment (= "global" in the UI) one Gitea org, one bot, one account
│ system, one inbox, one running process;
│ the surface a visitor first lands on.
└─ project ◀ NEW a named grouping + project settings;
│ owns exactly ONE content repo. No type.
└─ RFC collection a typed corpus: type, slug namespace,
│ catalog, philosophy, initial_state,
│ unreviewed flag, members. (= what the
│ original §22 called a "project".)
└─ entry an RFC / spec / feature, identified by
its slug within the collection.
```
- **Deployment / "global."** Unchanged top tier. Owns accounts, the §6
admission gate, the §15 inbox, the §1 bot, and the deployment landing
directory. Its management surface is **projects + global settings**.
- **Project** *(new)*. Belongs to exactly one deployment; never moves. Owns one
content repo (§A.2) and carries project settings (name, tagline, theme,
visibility, model universe). Has **no `type`** of its own. Its management
surface is **RFC collections + project settings**.
- **RFC collection.** A typed subfolder of its project's content repo (§A.2).
Carries everything the original §22 pinned on a "project": the immutable
`type` (§22.4a `document` | `specification` | `bdd` | …), the per-collection
slug namespace (§A.3), `initial_state` (§22.4b), the `unreviewed` flag
(§22.4c), catalog, philosophy. This is "closest to what OHM originally
managed as a single corpus."
- **Entry.** Unchanged (§2). Identified by its slug **within its collection**.
A collection belongs to exactly one project; a project to exactly one
deployment. Isolation (§22.1) now holds at the **collection** grain: an RFC,
branch, thread, star, or watch belongs to exactly one collection.
## A.2 Storage and git-truth
Two git sources, both read by the bot, both mirrored into cache tables the §4
way:
1. **The registry repo** (`projects.yaml`, located by `REGISTRY_REPO`, §22.2)
declares **projects**`id`, `name`, `content_repo`, settings, `visibility`,
`theme`, `enabled_models`. `content_repo` moves **up** from the collection
(original §22) to the project: a project owns exactly one content repo.
2. **Each project's content repo** declares its **collections** as typed
subfolders, each carrying a **`.collection.yaml` manifest** (the collection's
`type`, `visibility`, `initial_state`). The registry mirror walks the content
repo and reads these manifests, so collection configuration is git-truth and
survives a cache rebuild — exactly as entry frontmatter does.
```yaml
# projects.yaml (registry repo root)
deployment:
name: Wiggleverse
tagline: ...
projects:
- id: ohm
name: Open Human Model
content_repo: ohm-content # ONE repo; collections live inside it
visibility: public # gated | public | unlisted (§22.5)
theme: { accent: "#5b5bd6" }
enabled_models: [claude, gemini]
```
```yaml
# ohm-content/features/.collection.yaml (one per collection subfolder)
type: bdd # document | specification | bdd — immutable
visibility: gated # defaults to the project's, may narrow
initial_state: active # defaults from type (§22.4b)
name: Feature scenarios
```
```
ohm-content/
model/
.collection.yaml # type: document
intro.md
specs/
.collection.yaml # type: specification
runtime.md
features/
.collection.yaml # type: bdd
login.md
```
**Creation is in-app, wrapping a bot commit, at both tiers:**
- **+ New project** (a global Owner action): the bot **creates a Gitea content
repo** under the deployment org, **commits a project entry** to
`projects.yaml`, and the mirror picks it up.
- **+ New collection** (a project Owner / RFC Contributor-with-create action):
the bot **commits a new subfolder + `.collection.yaml`** to the project's
content repo; the mirror picks it up.
The in-app button is a thin convenience over a git write; nothing becomes app
state that git cannot rebuild. `projects` and `collections` cache rows are never
written from user actions directly — they flow from the mirror only (§22.2).
**Membership** (§B-roles) remains app state, as `rfc_collaborators` always has
been — it churns at user speed and is not document state.
## A.3 Identity and routing
The slug is unique **within a collection**; the fully-qualified identity is
`(project, collection, slug)`. `model/intro` and `specs/intro` coexist. No type
prefix, no numbers (the §22.4 retirement of `RFC-NNNN` allocation stands;
legacy `id` frontmatter remains a frozen, non-identity display label).
Canonical route:
```
/p/<project>/c/<collection>/e/<slug>
```
The `c/` segment keeps collection ids from colliding with reserved
project-level segments (project settings, the collection directory). Reserved
**collection-level** siblings (`proposals`, `philosophy`) sit under
`/p/<project>/c/<collection>/…`. The displayed entry noun ("RFC", "Spec",
"Feature") is the collection type's label (§22.4a), not part of the path.
The root `/` is the deployment landing: a **directory of projects** the visitor
can see (§22.5). `/p/<project>/` is the project landing: a **directory of
collections** in that project the visitor can see. Conveniences:
- `/p/<project>/` redirects to its sole collection when the project has exactly
one visible collection.
- `/` redirects to the sole visible project when there is exactly one (the N=1
case, §A.6).
⚠️ **Backcompat is heavier than first drafted.** `/p/<project>/e/<slug>` URLs
**are live** (v0.35.0), so adding the `/c/<collection>/` segment is a breaking
URL change: the shipped `/p/<project>/e/<slug>` must **308-redirect** to
`/p/<project>/c/<default-collection>/e/<slug>`, alongside the pre-multi-project
`/rfc/<slug>``/p/<default-project>/c/<default-collection>/…` redirect. Both
are handled in the migration (§A.6 / Part E).
---
# Part B — Roles and authorization
## B.1 One role vocabulary, attached at a scope
There is **one role enum — `{owner, contributor}`** — displayed as **Owner**
and **RFC Contributor**. A grant *attaches that role at a scope*: **global**,
**project**, or **collection**. "Owner at all levels, RFC Contributor at all
levels" is therefore literal — the same two words at every tier, not a fresh
pair invented per tier.
| Role | Capabilities within its scope's subtree |
|---|---|
| **Owner** | Superuser: manage settings and membership; create child projects/collections; act on any entry (merge on behalf, graduate, mark-reviewed, withdraw/reopen, set branch visibility). |
| **RFC Contributor** | Propose entries, create branches, open PRs, claim unclaimed super-drafts, participate in discussion. At **project** (or global) scope this additionally includes **creating collections** in that project — the "anyone at the project level with permission to create a collection" affordance. (A *collection*-scope grant cannot create sibling collections; creating one is a project-level action.) |
This **reconciles** the role names the prior drafts accumulated — they were
different words for the same idea:
| Prior spec term | Tier it lived at | Unified role |
|---|---|---|
| deployment `owner` / `admin` (§6.1) | global | **Owner** (global) |
| deployment `contributor` (§6.1) | global | **RFC Contributor** (global) |
| `project_admin` (M2 §22.6) | the corpus → now the **collection** | **Owner** (collection) |
| `project_contributor` (M2 §22.6) | the corpus → now the **collection** | **RFC Contributor** (collection) |
| `project_viewer` (M2 §22.6) | the corpus | *deferred* (read-only grant; not one of this pass's two) |
> **Scope-narrowing, not renaming.** Collapsing `owner`/`admin` into one
> **Owner** and dropping `viewer` for this pass are deliberate deferrals (the
> launch ask: "we don't need to get all permissions right yet"). When they
> return they **re-split out of** Owner / add a tier; they are not aliases of
> the unified roles. The richer set is future work.
## B.2 Inheritance and resolution
Grants inherit **downward**, are **additive**, and admit **no negative
override**:
- A grant at **global** covers every project and collection in the deployment.
- A grant at **project** covers every collection in that project.
- A grant at **collection** covers just that collection.
- You **cannot** grant a role at a parent scope and revoke it at a child (the
launch ask: "too complex"). Resolution never subtracts a parent grant.
Effective authority on an entry generalizes the §22.7 most-permissive union
from three layers to four (global → project → collection → per-entry):
```
effective authority on an entry =
global role (users.role)
project role (membership at the entry's project)
collection role (membership at the entry's collection)
per-entry authority (owners / arbiters / rfc_collaborators — §6.3, §12)
then minus §6.2 write-mute and §22.5 visibility (subtractive, as today)
```
**Per-entry authority is a distinct, finer layer — not a synonym.** `owners` /
`arbiters` / `rfc_collaborators` apply to *one specific entry* (§6.3, §12); the
three named scopes apply to a *subtree*. Per-entry authority is unchanged and
sits beneath collection in the union. `arbiter` is narrower than Owner (one
entry, not a subtree) and stays distinct.
## B.3 Schema impact
- `users.role` continues to carry the **global** role (deployment owner /
contributor).
- M2's `project_members(project_id, role)` rows were attached at what we now
call the **collection**. They generalize into a single polymorphic
**`memberships(scope_type ∈ {project, collection}, scope_id, user_id, role,
granted_by, granted_at)`** table; the M2 rows migrate to
`scope_type='collection'`. The **project** tier gets the same two roles,
freshly grantable.
- The M2 three-role enum (`viewer`/`contributor`/`admin`) collapses to
`{owner, contributor}`: `project_admin → owner`, `project_contributor →
contributor`, `project_viewer →` a read grant (no write) folded into
visibility, not a membership role this pass.
---
# Part C — Behavioral scenarios (BDD)
> These Gherkin scenarios are the behavioral spec for **role usage**,
> **invitation**, and **empty-state** experiences. They attach to the rewritten
> §22 as **§22.6a (role & invitation scenarios)**. They are written so they can
> *also* seed a `bdd`-type collection later (the framework dogfooding its own
> model). "Owner"/"RFC Contributor" are the unified roles (§B.1); a *scope* in
> the `Given` is global / project / collection.
>
> **Each scenario carries a `@S<n>` tag** naming the **slice** (Part E) that
> makes it pass — the "which scenarios are done after this slice" marker. After
> shipping slice S<n>, its acceptance gate is "every `@S<n>` scenario passes"
> (e.g. `--tags @S3`). The Part E table is the inverse index (slice →
> scenarios).
## C.1 Role usage — inheritance and the most-permissive union
```gherkin
Feature: Scope roles grant authority over a subtree
As a member of the deployment
I want a role granted at one tier to apply to everything beneath it
So that I can be invited once and work across the right set of collections
Background:
Given a deployment with a project "ohm"
And "ohm" owns collections "model" (document) and "features" (bdd)
@S3
Scenario: Collection RFC Contributor may propose only in that collection
Given "ada" is RFC Contributor at collection "ohm/model"
When "ada" opens the propose form in "ohm/model"
Then she may submit a new entry
When "ada" opens "ohm/features"
Then she sees it read-only and the propose action is not offered
@S3
Scenario: Project RFC Contributor may propose in every collection of the project
Given "ben" is RFC Contributor at project "ohm"
Then "ben" may propose in "ohm/model"
And "ben" may propose in "ohm/features"
And a collection added to "ohm" later is writable by "ben" with no new grant
@S3
Scenario: Global RFC Contributor may propose in every collection of every project
Given a second project "acme" with collection "acme/specs"
And "cleo" is RFC Contributor at global scope
Then "cleo" may propose in "ohm/model" and "acme/specs"
@S3
Scenario: Collection Owner administers one collection only
Given "dan" is Owner at collection "ohm/features"
Then "dan" may graduate, mark-reviewed, and manage membership in "ohm/features"
But "dan" may not change "ohm" project settings
And "dan" may not act on entries in "ohm/model"
@S3
Scenario: Project Owner administers all collections and may create more
Given "eve" is Owner at project "ohm"
Then "eve" may manage membership in "ohm/model" and "ohm/features"
And "eve" may edit "ohm" project settings
And "eve" may create a new collection in "ohm"
@S3
Scenario: Most-permissive union — the higher grant wins
Given "fay" is RFC Contributor at collection "ohm/model"
And "fay" is Owner at project "ohm"
Then "fay" acts as Owner in "ohm/model"
@S3
Scenario: No negative override — a child cannot subtract a parent grant
Given "gil" is RFC Contributor at project "ohm"
Then there is no control to remove "gil" from "ohm/model" while keeping the project grant
And "gil" can propose in "ohm/model"
@S3
Scenario: A granted account with no scope role sees only public content
Given "hana" has a granted deployment account but no global, project, or collection role
Then "hana" may read public collections under the §6.1 anonymous-read contract
But "hana" is not offered the propose action anywhere
And gated projects and collections do not appear for her
```
## C.2 Invitation — who may invite whom, at which scope
```gherkin
Feature: Inviting users to a scope role
As an Owner of a scope
I want to grant Owner or RFC Contributor at my scope or any scope beneath it
So that collaborators get exactly the reach they need
@S4
Scenario: Project Owner invites at project scope (covers all collections)
Given "eve" is Owner at project "ohm"
When "eve" invites "ivy" as RFC Contributor at project "ohm"
Then a membership row is written at scope project "ohm"
And "ivy" receives a §15 notification naming the project and role
And "ivy" may propose in every collection of "ohm"
@S4
Scenario: Owner invites at a specific collection
When "eve" invites "jo" as RFC Contributor at collection "ohm/features"
Then a membership row is written at scope collection "ohm/features"
And "jo" may propose in "ohm/features" but not "ohm/model"
@S4
Scenario: Invitation reach is bounded by the inviter's scope
Given "dan" is Owner at collection "ohm/features"
Then "dan" may invite users to roles in "ohm/features"
But "dan" is not offered the control to invite at project "ohm" or global scope
@S4
Scenario: RFC Contributors do not manage membership
Given "ben" is RFC Contributor at project "ohm"
Then "ben" may propose and create collections in "ohm"
But "ben" is not offered any invite control (membership is an Owner capability)
@S4
Scenario: The invite UI offers no grant-at-parent-revoke-at-child option
Given "eve" is Owner at project "ohm"
When "eve" opens the invite control for "ivy" at project "ohm"
Then she may choose role Owner or RFC Contributor and scope project or a single collection
But there is no option to grant at "ohm" and exclude a child collection
@S4
Scenario: Re-inviting at a broader scope supersedes the narrower grant
Given "jo" is RFC Contributor at collection "ohm/features"
When "eve" invites "jo" as RFC Contributor at project "ohm"
Then "jo" has the role across all of "ohm"
And the redundant collection-scope row is removed or shown as subsumed
@S4
Scenario: A pending deployment account cannot be granted write
Given "kim" has permission_state "pending" at the deployment
When "eve" invites "kim" as RFC Contributor at project "ohm"
Then the grant is recorded but confers no write capability until "kim" is granted at the deployment (§6)
```
## C.3 Empty-state experiences
```gherkin
Feature: Empty states at each tier
As a viewer of a tier with nothing in it yet
I want a clear, role-appropriate empty state
So that I know whether there is an action to take or simply nothing to see
@S5
Scenario: Global directory with no projects — Owner
Given a deployment with no projects
And "root" is Owner at global scope
When "root" lands on "/"
Then she sees an empty directory with a "Create your first project" call to action
@S5
Scenario: Global directory with no visible projects — non-owner
Given a deployment whose only projects are gated
And "vee" is a granted account with no roles
When "vee" lands on "/"
Then she sees an empty directory with no create action
And a note that there is nothing shared with her yet
@S4
Scenario: Project with no collections — project Owner
Given project "ohm" with no collections
And "eve" is Owner at project "ohm"
When "eve" lands on "/p/ohm/"
Then she sees an empty collection directory with a "Create your first collection" call to action
And the action lets her choose a type and subfolder
@S4
Scenario: Project with no collections — RFC Contributor without create rights
Given project "ohm" with no collections
And "ben" is RFC Contributor at collection scope elsewhere only
When "ben" lands on "/p/ohm/"
Then he sees an empty collection directory with no create action
@S4
Scenario: Collection with no entries — a contributor
Given collection "ohm/model" with no entries
And "ada" is RFC Contributor at collection "ohm/model"
When "ada" lands on "/p/ohm/c/model/"
Then she sees an empty catalog with a "Propose the first entry" call to action
@S2
Scenario: Collection with no entries — an anonymous reader
Given a public collection "ohm/model" with no entries
When an anonymous visitor lands on "/p/ohm/c/model/"
Then they see an empty catalog with no propose action and a sign-in prompt
@S1
Scenario: Single-collection project skips the directory
Given project "ohm" with exactly one visible collection "model"
When a visitor lands on "/p/ohm/"
Then they are redirected to "/p/ohm/c/model/"
@S1
Scenario: Single-project deployment skips the directory
Given a deployment with exactly one visible project "ohm"
When a visitor lands on "/"
Then they are redirected to "/p/ohm/"
```
---
# Part D — Amendments to the original §22 draft
Applied in place when §22 is rewritten; listed here as the change surface.
- **§22 preamble / §22.1.** "A deployment hosts N projects, each a corpus" →
"a deployment hosts N **projects**, each owning one content repo and holding
N **RFC collections**, each collection a typed corpus." Isolation moves to the
collection grain.
- **§22.2 Registry.** `projects.yaml` declares projects with one `content_repo`
each (no per-collection `content_repo`). New: collections are declared by
`.collection.yaml` manifests inside the content repo; the mirror reads them.
In-app create-project / create-collection actions wrap bot commits.
- **§22.3 Content repos.** "One per project" (not per collection); collections
are subfolders within it.
- **§22.4 / §22.4a-c.** Slug is unique **per collection**. `type`,
`initial_state`, and `unreviewed` are **collection** properties (re-homed from
"project"). Unchanged otherwise.
- **§22.5 Visibility.** Applies at **both** project and collection. A collection
defaults to its project's visibility and may narrow it; reading/writing a
collection requires passing both gates.
- **§22.6 Membership and roles → the unified model (Part B).** Replace the three
`project_*` roles with `{owner, contributor}` at `{global, project,
collection}` via a polymorphic `memberships` table. Add **§22.6a** = the
Part C scenarios.
- **§22.7 Composition.** Four-layer most-permissive union (global → project →
collection → per-entry); no negative override.
- **§22.9 / §22.10 Branding & routing.** Routes gain the collection segment:
`/p/<project>/c/<collection>/…`. `GET /api/deployment` lists visible projects;
add `GET /api/projects/:id` (lists visible collections + project settings) and
`GET /api/projects/:id/collections/:cid` (collection settings incl. `type`).
- **§22.11 Notifications / §22.13 migration / §5 amendments.** `project_id`
becomes `collection_id` on every entry-scoped row (the corpus grain is now the
collection); a separate `project_id` exists only on the `collections` table
and project-scoped rows. The §22.13 default project gains a default collection
(§A.6 below).
---
# Part E — Revised slicing plan (the roadmap re-slot)
**Strategy (session 0072, decided on corrected facts).** The two-tier model is
shipped end-to-end (v0.39.0): migration 028 keyed entries `(project_id, slug)`;
v0.35.0 shipped `/p/<project>/` routing + live `/p/<project>/e/<slug>` URLs;
v0.37.0/0.38.0 shipped per-project read + propose. Inserting the third tier is
therefore an **evolution of a shipped system**. The chosen mapping **adds a
collection grain *beneath* today's project** — the shipped `projects` table
stays the grouping tier (it already owns `content_repo`, where §A.2 wants it),
a new `collections` table holds the per-corpus fields, and entries re-key to the
finer `(collection_id, slug)`.
**Slicing principle (session 0072): every slice ends in a *usable* deployment,
and declares the Part C scenarios it makes pass** (its `@S<n>` tag). "Usable"
means the deployment runs and either gains a capability or provably loses none
(N=1 unchanged). A slice is done when its `@S<n>` scenarios are green.
- **Landed, unchanged (v0.39.0):** M1M2, M3-backend Plan A **and** Plan B
(read+propose, mig 028), M3-frontend (`/p/<project>/` routing), §22.13
re-stamp. None of this is rebuilt; it is *evolved* by the slices below.
- **S1 — The collection grain exists (invisible default).** Migration 029 +
backend threading + the default-routing redirect, shipped **together** (they
are coupled — renaming `project_id``collection_id` breaks every reader until
the code is threaded, so a green tree needs both). Migration 029
(`029_collections.sql`): (1) add a `collections` table
`(id, project_id, type, subfolder, initial_state, visibility, name,
registry_sha)`; (2) move the per-corpus fields (`type`, `initial_state`,
visibility) **down** from `projects` (leaving it `(id, content_repo,
visibility, name, tagline, theme, enabled_models, …)`); (3) create one default
collection per project (id `default`, `subfolder` = repo root); (4) re-key
every entry-scoped table `(project_id, slug)``(collection_id, slug)` via the
`028_project_scoped_keys.sql` rebuild pattern (`__new`, copy, drop, rename,
FK-off + `foreign_key_check`); (5) generalize `project_members`
`memberships(scope_type ∈ {project, collection}, …)`, collapsing the role enum
(§B.3). Then thread `collection_id` through `app/auth.py` / `app/projects.py`
/ `app/cache.py` / the `api_*` writers, and **308** `/p/<project>/e/<slug>`
`/p/<project>/c/<default>/e/<slug>`. **Usable end-state:** the deployment runs
exactly as before, now with a real collection layer and one extra path segment.
**Completes:** `@S1` (the single-collection / single-project redirect skips).
- **S2 — Create & navigate a second collection.** *(Shipped v0.41.0.)* Teach the
registry mirror to
read `.collection.yaml`; add the bot-commit-wrapped **create-collection**
endpoint (authorized by existing deployment owner/admin for now — the scoped
role surface lands in S3); the project collection-directory at `/p/<project>/`;
collection-scoped propose/serve under `/p/<project>/c/<collection>/`.
**Usable end-state:** an admin creates a `bdd` collection beside the document
one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of
an empty collection catalog).
- **S3 — Scope-role enforcement.** *(Shipped v0.42.0.)* The four-layer
most-permissive resolver (§B.2) over `{owner, contributor}` grants at
`{global, project, collection}` (migration 030 adds the `global` scope_type),
with grants applied administratively (DB / the Owner-authorized create
surface); every write gate re-checked under the collection axis. **Plus the
operator's S3 visibility requirements:** collection-grain visibility is
enforced — a `gated` collection is hidden from the public (404, omitted from
the directory) yet visible to scope-role contributors; a collection's
visibility may be set only as strict or stricter than its project's
(`public` < `unlisted` < `gated`). **Keystone reconciliation (session 0076):**
§B.1/§B.3's literal "deployment contributor = global RFC Contributor"
contradicted the C.1 "hana" scenario and the M2 implicit-public baseline;
resolved as — a plain granted account is a granted *account*, not a
write-everywhere global role; "global RFC Contributor" is an explicit
`scope_type='global'` grant; the implicit-public write baseline is
grandfathered onto the migration-seeded `default` collection only (N=1
preserved). *Flag for the SPEC merge (S6): reinterprets §B.1/§B.3.* **Usable
end-state:** a user granted RFC Contributor at a scope can contribute across
exactly that subtree, Owners administer their subtree, and a collection can be
hidden from the public. **Completes:** `@S3` (all of C.1 — role usage,
inheritance, union, no-negative-override).
- **S4 — Invitation surfaces + role-aware empty states.** *(Shipped v0.43.0.)*
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.
Modelled as a **direct grant** to an existing account looked up by email (the
C.2 scenarios write the membership row immediately and §15-notify an existing
user — no accept round-trip; inviting a not-yet-account email is out of S4
scope, handled by the admin-create-invite path). **Usable end-state:** an Owner
invites collaborators at the right scope from the UI. **Completes:** `@S4` (all
of C.2 — invitation; plus the project/collection empty states C3.3C3.5).
- **S5 — In-app create-project + the global directory.** *(Shipped v0.44.0.)*
The global-Owner
**create-project** action (bot provisions a Gitea content repo + commits to
`projects.yaml`); the deployment directory empty states. Modelled as a
global-Owner gate (`auth.can_create_project`: a deployment owner/admin or an
explicit `scope_type='global'` Owner grant) over `POST /api/projects`; the
content repo defaults to `<id>-content` and is seeded with a `README.md` so
`main` exists. The deployment payload gains `viewer.can_create_project` +
`default_project_readable` so the directory renders the role-aware empty state
rather than bouncing into an unreadable/absent default. **Usable end-state:**
a global Owner stands up a new project end-to-end from the UI. **Completes:**
`@S5` (the global-directory empty states C3.1C3.2).
- **S6 — Type modules, membership lifecycle, hardening, SPEC merge.** Per-type
frontmatter + surfaces selected on the **collection's** `type`; request-to-join
+ cross-collection inbox; per-collection `enabled_models`; the registry +
manifest format in `docs/DEPLOYMENTS.md`; two-project / multi-collection e2e;
the §20.4 changelog + upgrade-steps; the SPEC merge (Part A applied, Part D in
place). **Usable end-state:** the model is fully realized and merged into
`SPEC.md`. **Completes:** type-specific scenarios (added in S6, beyond Part C's
role focus).
- *Shipped in S6 core (v0.45.0):* the SPEC merge, per-collection
`enabled_models`, the type-driven entry noun (§22.4a item 2).
- *Shipped as the S6 remainder (v0.46.0):* request-to-join + the
cross-collection inbox (§22.8).
- *Spec'd, not yet built — the last S6 item:* the per-type **frontmatter
schemas** (§22.4a item 1) and **surfaces** (§22.4a item 3, the
`specification` release-planning + `bdd` scenario/coverage views). The
discovery/spec pass + BDD scenarios + slicing (S7aS7c) are in
[`2026-06-06-per-type-surfaces.md`](./2026-06-06-per-type-surfaces.md).
### Slice → scenario index (the inverse of the `@S<n>` tags)
| Slice | Usable thing it ships | Completes (`@S<n>`) |
|---|---|---|
| **S1** | collection grain + default + redirects; N=1 unchanged | C3.7, C3.8 (`@S1`) |
| **S2** | create + navigate + propose a 2nd collection | C3.6 (`@S2`) |
| **S3** | scope-role enforcement across global/project/collection | C1.1C1.8 (`@S3`) |
| **S4** | invitation UI + role-aware empty states | C2.1C2.7, C3.3C3.5 (`@S4`) |
| **S5** | in-app create-project + global directory | C3.1, C3.2 (`@S5`) |
| **S6** | type surfaces, lifecycle, hardening, SPEC merge | type-specific (new) |
Each slice is a candidate single session: it lands a usable deployment and a
runnable acceptance gate (`--tags @S<n>`). **S1 is the natural first session**
the coupled migration 029 + threading + redirect, sized as one usable increment
(answering the in-session question: bundled, it is right-sized, not too much).
## E.1 (= §A.6) Migration — the default collection (the N=1 case)
A deployment on the shipped two-tier schema (v0.39.0) is migrated by 029 so it
keeps running unchanged:
1. The existing `projects` row **stays as the project** (it already owns
`content_repo` and its config-derived `id` from §22.13 step 1).
2. A **default collection** (`id='default'`, `subfolder` = repo root) is created
per project, inheriting that project's `type` / `initial_state` / visibility;
those fields are then dropped from `projects`.
3. Every entry-scoped `project_id` row is re-keyed with the default
`collection_id` (PK `(project_id, slug)``(collection_id, slug)`).
4. `project_members` rows migrate to `memberships(scope_type='collection')` on
the default collection, role-collapsed (§B.3).
5. **308 redirects:** the shipped `/p/<project>/e/<slug>`
`/p/<project>/c/<default>/e/<slug>`, and the pre-multi-project `/rfc/<slug>`
/ `/proposals/<n>` → their `/p/<project>/c/<default>/…` equivalents.
Until a second collection is added, the deployment is functionally identical to
before, with one extra path segment. This is the §20.4 upgrade-steps content for
the release.
## E.2 Scope of the first implementation pass
Per the launch ask — "we don't need to get all permissions right yet, just have
Owner at all levels, and RFC Contributor at the global, project, and RFC
collection level" — the **role surface** this pass implements is exactly
`{owner, contributor}` × `{global, project, collection}` (Part B), plus the
unchanged per-entry layer. `viewer`, the owner/admin split, request-to-join
nuances, and per-type role labels are deferred (§B.1 note).