Add 'specs' collection: rfc-app solution designs for in-app review
regenerate-readme-index / noop (push) Has been cancelled
regenerate-readme-index / noop (push) Has been cancelled
Seeds the framework's docs/design/*.md as a document collection on the ohm project (subfolder specs/, unlisted) so the solution designs can be reviewed and collaborated on in the RFC app until graduated. Dogfood from session OHM-0090.0. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,689 @@
|
||||
---
|
||||
slug: 2026-06-05-three-tier-projects-collections
|
||||
title: "Three-Tier: Projects → Collections (§22)"
|
||||
state: super-draft
|
||||
id: null
|
||||
repo: null
|
||||
proposed_by: ben.stull@wiggleverse.org
|
||||
proposed_at: '2026-06-05'
|
||||
graduated_at: null
|
||||
graduated_by: null
|
||||
owners:
|
||||
- ben.stull@wiggleverse.org
|
||||
arbiters: []
|
||||
tags:
|
||||
- solution-design
|
||||
---
|
||||
|
||||
# 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 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/<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):** M1–M2, 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.3–C3.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.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).
|
||||
- *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 (S7a–S7c) 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.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<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).
|
||||
Reference in New Issue
Block a user