§22 spec: re-cut Part E into usable BDD-tagged slices (S1-S6)

Per operator (session 0072): every slice must end in a usable deployment and
declare which Part C scenarios it makes pass. Tag all 23 Gherkin scenarios with
@S<n> (the slice that completes them) and re-cut Part E from layer-by-layer
(N1-N6) to usable increments (S1-S6) with a slice->scenario index table. S1
bundles the coupled migration 029 + threading + redirect as one right-sized
first session; S2 second collection; S3 role enforcement; S4 invitation;
S5 create-project + directory; S6 types + SPEC merge.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-05 06:42:12 -07:00
parent 87279fc545
commit 2f5d09aef5
@@ -275,6 +275,12 @@ entry, not a subtree) and stays distinct.
> *also* seed a `bdd`-type collection later (the framework dogfooding its own > *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 > model). "Owner"/"RFC Contributor" are the unified roles (§B.1); a *scope* in
> the `Given` is global / project / collection. > 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 ## C.1 Role usage — inheritance and the most-permissive union
@@ -288,6 +294,7 @@ Feature: Scope roles grant authority over a subtree
Given a deployment with a project "ohm" Given a deployment with a project "ohm"
And "ohm" owns collections "model" (document) and "features" (bdd) And "ohm" owns collections "model" (document) and "features" (bdd)
@S3
Scenario: Collection RFC Contributor may propose only in that collection Scenario: Collection RFC Contributor may propose only in that collection
Given "ada" is RFC Contributor at collection "ohm/model" Given "ada" is RFC Contributor at collection "ohm/model"
When "ada" opens the propose form in "ohm/model" When "ada" opens the propose form in "ohm/model"
@@ -295,39 +302,46 @@ Feature: Scope roles grant authority over a subtree
When "ada" opens "ohm/features" When "ada" opens "ohm/features"
Then she sees it read-only and the propose action is not offered 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 Scenario: Project RFC Contributor may propose in every collection of the project
Given "ben" is RFC Contributor at project "ohm" Given "ben" is RFC Contributor at project "ohm"
Then "ben" may propose in "ohm/model" Then "ben" may propose in "ohm/model"
And "ben" may propose in "ohm/features" And "ben" may propose in "ohm/features"
And a collection added to "ohm" later is writable by "ben" with no new grant 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 Scenario: Global RFC Contributor may propose in every collection of every project
Given a second project "acme" with collection "acme/specs" Given a second project "acme" with collection "acme/specs"
And "cleo" is RFC Contributor at global scope And "cleo" is RFC Contributor at global scope
Then "cleo" may propose in "ohm/model" and "acme/specs" Then "cleo" may propose in "ohm/model" and "acme/specs"
@S3
Scenario: Collection Owner administers one collection only Scenario: Collection Owner administers one collection only
Given "dan" is Owner at collection "ohm/features" Given "dan" is Owner at collection "ohm/features"
Then "dan" may graduate, mark-reviewed, and manage membership in "ohm/features" Then "dan" may graduate, mark-reviewed, and manage membership in "ohm/features"
But "dan" may not change "ohm" project settings But "dan" may not change "ohm" project settings
And "dan" may not act on entries in "ohm/model" And "dan" may not act on entries in "ohm/model"
@S3
Scenario: Project Owner administers all collections and may create more Scenario: Project Owner administers all collections and may create more
Given "eve" is Owner at project "ohm" Given "eve" is Owner at project "ohm"
Then "eve" may manage membership in "ohm/model" and "ohm/features" Then "eve" may manage membership in "ohm/model" and "ohm/features"
And "eve" may edit "ohm" project settings And "eve" may edit "ohm" project settings
And "eve" may create a new collection in "ohm" And "eve" may create a new collection in "ohm"
@S3
Scenario: Most-permissive union — the higher grant wins Scenario: Most-permissive union — the higher grant wins
Given "fay" is RFC Contributor at collection "ohm/model" Given "fay" is RFC Contributor at collection "ohm/model"
And "fay" is Owner at project "ohm" And "fay" is Owner at project "ohm"
Then "fay" acts as Owner in "ohm/model" Then "fay" acts as Owner in "ohm/model"
@S3
Scenario: No negative override — a child cannot subtract a parent grant Scenario: No negative override — a child cannot subtract a parent grant
Given "gil" is RFC Contributor at project "ohm" Given "gil" is RFC Contributor at project "ohm"
Then there is no control to remove "gil" from "ohm/model" while keeping the project grant Then there is no control to remove "gil" from "ohm/model" while keeping the project grant
And "gil" can propose in "ohm/model" And "gil" can propose in "ohm/model"
@S3
Scenario: A granted account with no scope role sees only public content 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 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 Then "hana" may read public collections under the §6.1 anonymous-read contract
@@ -343,6 +357,7 @@ Feature: Inviting users to a scope role
I want to grant Owner or RFC Contributor at my scope or any scope beneath it 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 So that collaborators get exactly the reach they need
@S4
Scenario: Project Owner invites at project scope (covers all collections) Scenario: Project Owner invites at project scope (covers all collections)
Given "eve" is Owner at project "ohm" Given "eve" is Owner at project "ohm"
When "eve" invites "ivy" as RFC Contributor at project "ohm" When "eve" invites "ivy" as RFC Contributor at project "ohm"
@@ -350,33 +365,39 @@ Feature: Inviting users to a scope role
And "ivy" receives a §15 notification naming the project and role And "ivy" receives a §15 notification naming the project and role
And "ivy" may propose in every collection of "ohm" And "ivy" may propose in every collection of "ohm"
@S4
Scenario: Owner invites at a specific collection Scenario: Owner invites at a specific collection
When "eve" invites "jo" as RFC Contributor at collection "ohm/features" When "eve" invites "jo" as RFC Contributor at collection "ohm/features"
Then a membership row is written at scope 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" And "jo" may propose in "ohm/features" but not "ohm/model"
@S4
Scenario: Invitation reach is bounded by the inviter's scope Scenario: Invitation reach is bounded by the inviter's scope
Given "dan" is Owner at collection "ohm/features" Given "dan" is Owner at collection "ohm/features"
Then "dan" may invite users to roles in "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 But "dan" is not offered the control to invite at project "ohm" or global scope
@S4
Scenario: RFC Contributors do not manage membership Scenario: RFC Contributors do not manage membership
Given "ben" is RFC Contributor at project "ohm" Given "ben" is RFC Contributor at project "ohm"
Then "ben" may propose and create collections in "ohm" Then "ben" may propose and create collections in "ohm"
But "ben" is not offered any invite control (membership is an Owner capability) 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 Scenario: The invite UI offers no grant-at-parent-revoke-at-child option
Given "eve" is Owner at project "ohm" Given "eve" is Owner at project "ohm"
When "eve" opens the invite control for "ivy" 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 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 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 Scenario: Re-inviting at a broader scope supersedes the narrower grant
Given "jo" is RFC Contributor at collection "ohm/features" Given "jo" is RFC Contributor at collection "ohm/features"
When "eve" invites "jo" as RFC Contributor at project "ohm" When "eve" invites "jo" as RFC Contributor at project "ohm"
Then "jo" has the role across all of "ohm" Then "jo" has the role across all of "ohm"
And the redundant collection-scope row is removed or shown as subsumed And the redundant collection-scope row is removed or shown as subsumed
@S4
Scenario: A pending deployment account cannot be granted write Scenario: A pending deployment account cannot be granted write
Given "kim" has permission_state "pending" at the deployment Given "kim" has permission_state "pending" at the deployment
When "eve" invites "kim" as RFC Contributor at project "ohm" When "eve" invites "kim" as RFC Contributor at project "ohm"
@@ -391,12 +412,14 @@ Feature: Empty states at each tier
I want a clear, role-appropriate empty state I want a clear, role-appropriate empty state
So that I know whether there is an action to take or simply nothing to see So that I know whether there is an action to take or simply nothing to see
@S5
Scenario: Global directory with no projects — Owner Scenario: Global directory with no projects — Owner
Given a deployment with no projects Given a deployment with no projects
And "root" is Owner at global scope And "root" is Owner at global scope
When "root" lands on "/" When "root" lands on "/"
Then she sees an empty directory with a "Create your first project" call to action 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 Scenario: Global directory with no visible projects — non-owner
Given a deployment whose only projects are gated Given a deployment whose only projects are gated
And "vee" is a granted account with no roles And "vee" is a granted account with no roles
@@ -404,6 +427,7 @@ Feature: Empty states at each tier
Then she sees an empty directory with no create action Then she sees an empty directory with no create action
And a note that there is nothing shared with her yet And a note that there is nothing shared with her yet
@S4
Scenario: Project with no collections — project Owner Scenario: Project with no collections — project Owner
Given project "ohm" with no collections Given project "ohm" with no collections
And "eve" is Owner at project "ohm" And "eve" is Owner at project "ohm"
@@ -411,28 +435,33 @@ Feature: Empty states at each tier
Then she sees an empty collection directory with a "Create your first collection" call to action 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 And the action lets her choose a type and subfolder
@S4
Scenario: Project with no collections — RFC Contributor without create rights Scenario: Project with no collections — RFC Contributor without create rights
Given project "ohm" with no collections Given project "ohm" with no collections
And "ben" is RFC Contributor at collection scope elsewhere only And "ben" is RFC Contributor at collection scope elsewhere only
When "ben" lands on "/p/ohm/" When "ben" lands on "/p/ohm/"
Then he sees an empty collection directory with no create action Then he sees an empty collection directory with no create action
@S4
Scenario: Collection with no entries — a contributor Scenario: Collection with no entries — a contributor
Given collection "ohm/model" with no entries Given collection "ohm/model" with no entries
And "ada" is RFC Contributor at collection "ohm/model" And "ada" is RFC Contributor at collection "ohm/model"
When "ada" lands on "/p/ohm/c/model/" When "ada" lands on "/p/ohm/c/model/"
Then she sees an empty catalog with a "Propose the first entry" call to action Then she sees an empty catalog with a "Propose the first entry" call to action
@S2
Scenario: Collection with no entries — an anonymous reader Scenario: Collection with no entries — an anonymous reader
Given a public collection "ohm/model" with no entries Given a public collection "ohm/model" with no entries
When an anonymous visitor lands on "/p/ohm/c/model/" 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 Then they see an empty catalog with no propose action and a sign-in prompt
@S1
Scenario: Single-collection project skips the directory Scenario: Single-collection project skips the directory
Given project "ohm" with exactly one visible collection "model" Given project "ohm" with exactly one visible collection "model"
When a visitor lands on "/p/ohm/" When a visitor lands on "/p/ohm/"
Then they are redirected to "/p/ohm/c/model/" Then they are redirected to "/p/ohm/c/model/"
@S1
Scenario: Single-project deployment skips the directory Scenario: Single-project deployment skips the directory
Given a deployment with exactly one visible project "ohm" Given a deployment with exactly one visible project "ohm"
When a visitor lands on "/" When a visitor lands on "/"
@@ -489,61 +518,92 @@ therefore an **evolution of a shipped system**. The chosen mapping **adds a
collection grain *beneath* today's project** — the shipped `projects` table collection grain *beneath* today's project** — the shipped `projects` table
stays the grouping tier (it already owns `content_repo`, where §A.2 wants it), 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 a new `collections` table holds the per-corpus fields, and entries re-key to the
finer `(collection_id, slug)`. The migrated default keeps every deployment finer `(collection_id, slug)`.
running; the new `/c/<collection>/` URL segment is added with 308s off the
now-legacy two-tier URLs. **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 - **Landed, unchanged (v0.39.0):** M1M2, M3-backend Plan A **and** Plan B
(read+propose, mig 028), M3-frontend (`/p/<project>/` routing), §22.13 (read+propose, mig 028), M3-frontend (`/p/<project>/` routing), §22.13
re-stamp. None of this is rebuilt; it is *evolved* by migration 029 below. re-stamp. None of this is rebuilt; it is *evolved* by the slices below.
- **N1 — migration 029: add the collection grain.** A new - **S1 — The collection grain exists (invisible default).** Migration 029 +
`029_collections.sql` that: (1) **adds a `collections` table** 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, `(id, project_id, type, subfolder, initial_state, visibility, name,
registry_sha)`; (2) **moves the per-corpus fields** (`type`, `initial_state`, registry_sha)`; (2) move the per-corpus fields (`type`, `initial_state`,
visibility) from the shipped `projects` table **down** to `collections`, visibility) **down** from `projects` (leaving it `(id, content_repo,
leaving `projects` as `(id, content_repo, visibility, name, tagline, theme, visibility, name, tagline, theme, enabled_models, …)`); (3) create one default
enabled_models, …)`; (3) **creates one default collection per existing collection per project (id `default`, `subfolder` = repo root); (4) re-key
project** (id `default`, `subfolder` = repo root, inheriting that project's every entry-scoped table `(project_id, slug)``(collection_id, slug)` via the
`type`/`initial_state`); (4) **re-keys every entry-scoped table** from `028_project_scoped_keys.sql` rebuild pattern (`__new`, copy, drop, rename,
`(project_id, slug)` to `(collection_id, slug)` via the same SQLite FK-off + `foreign_key_check`); (5) generalize `project_members`
rebuild procedure migration 028 used (the `028_project_scoped_keys.sql` `memberships(scope_type ∈ {project, collection}, …)`, collapsing the role enum
pattern — `__new` table, copy, drop, rename, FK-off + `foreign_key_check`), (§B.3). Then thread `collection_id` through `app/auth.py` / `app/projects.py`
stamping the default `collection_id`; (5) **generalizes `project_members` / `app/cache.py` / the `api_*` writers, and **308** `/p/<project>/e/<slug>`
`memberships(scope_type ∈ {project, collection}, scope_id, …)`**, migrating `/p/<project>/c/<default>/e/<slug>`. **Usable end-state:** the deployment runs
existing rows to `scope_type='collection'` on the default collection and exactly as before, now with a real collection layer and one extra path segment.
collapsing the role enum to `{owner, contributor}` (§B.3). **Completes:** `@S1` (the single-collection / single-project redirect skips).
- **N2 — backend collection scoping.** Thread `collection_id` through the - **S2 — Create & navigate a second collection.** Teach the registry mirror to
resolution gates that migration 028/Plan B threaded `project_id` through read `.collection.yaml`; add the bot-commit-wrapped **create-collection**
(`app/auth.py`, `app/projects.py`, `app/cache.py`, the `api_*` writers); endpoint (authorized by existing deployment owner/admin for now — the scoped
generalize the §22.7 union to the four-layer resolver (§B.2); add the role surface lands in S3); the project collection-directory at `/p/<project>/`;
`GET /api/projects/:id/collections` and collection-scoped propose/serve under `/p/<project>/c/<collection>/`.
`GET /api/projects/:id/collections/:cid` surfaces; scope propose/serve/PR **Usable end-state:** an admin creates a `bdd` collection beside the document
endpoints to `(project, collection)`. one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of
an empty collection catalog).
- **N3 — manifests & in-app create.** Teach the registry mirror to read - **S3 — Scope-role enforcement.** The four-layer most-permissive resolver
`.collection.yaml` from each project's content repo; add the (§B.2) over `{owner, contributor}` grants at `{global, project, collection}`,
bot-commit-wrapped **create-collection** (project Owner / RFC Contributor) with grants applied administratively (DB / admin endpoint); every write gate
and **create-project** (global Owner) endpoints (§A.2); mirror new re-checked under the collection axis. **Usable end-state:** a user granted
collections into `collections` rows. RFC Contributor at a scope can contribute across exactly that subtree, and
Owners administer their subtree. **Completes:** `@S3` (all of C.1 — role usage,
inheritance, union, no-negative-override).
- **N4 — frontend three-tier routing.** Add the `/c/<collection>/` segment: - **S4 — Invitation surfaces + role-aware empty states.** The invite UI
`/p/<project>/c/<collection>/e/<slug>`; the project (collection) directory at (Owner-only) granting Owner/RFC Contributor at a scope or any scope beneath it,
`/p/<project>/`; the single-collection redirect (§A.3); **308** the shipped with §15 notifications and the broader-scope-supersedes rule; the
`/p/<project>/e/<slug>``/p/<project>/c/<default>/e/<slug>`; per-collection create-first-collection / propose-first empty states keyed to the actor's role.
chrome layered under the existing per-project chrome. **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).
- **N5 — roles surface (this pass's scope).** The `{owner, contributor}` × - **S5 — In-app create-project + the global directory.** The global-Owner
`{global, project, collection}` grants (Part B) with the invite surfaces and **create-project** action (bot provisions a Gitea content repo + commits to
empty states of Part C as acceptance tests. (Richer roles deferred, §B.1.) `projects.yaml`); the deployment directory empty states. **Usable end-state:**
a global Owner stands up a new project end-to-end from the UI. **Completes:**
`@S5` (the global-directory empty states C3.1C3.2).
- **N6 — type modules / membership lifecycle / hardening.** Carries the original - **S6 — Type modules, membership lifecycle, hardening, SPEC merge.** Per-type
§22 M5M7 forward, now selecting on the **collection's** `type`: per-type frontmatter + surfaces selected on the **collection's** `type`; request-to-join
frontmatter + surfaces; request-to-join + cross-collection inbox; per-collection + cross-collection inbox; per-collection `enabled_models`; the registry +
`enabled_models`; the registry + manifest format in `docs/DEPLOYMENTS.md`; manifest format in `docs/DEPLOYMENTS.md`; two-project / multi-collection e2e;
two-project / multi-collection e2e; the §20.4 changelog + upgrade-steps; the the §20.4 changelog + upgrade-steps; the SPEC merge (Part A applied, Part D in
SPEC merge (Part A applied, Part D amendments in place). place). **Usable end-state:** the model is fully realized and merged into
`SPEC.md`. **Completes:** type-specific scenarios (added in S6, beyond Part C's
role focus).
### Slice → scenario index (the inverse of the `@S<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) ## E.1 (= §A.6) Migration — the default collection (the N=1 case)