§22 three-tier refactor spec: project → RFC collection + unified roles + BDDs
Splits the original §22 two-tier model (deployment → project=corpus) into
three tiers (deployment → project → RFC collection). Project owns one
content repo; collections are typed subfolders declared by .collection.yaml
manifests (git-truth). Reconciles the accumulated role vocabulary onto one
{owner, contributor} enum attached at {global, project, collection}, with
downward additive inheritance and no negative override. Adds BDD scenarios
(Part C) for role usage, invitation, and empty states. Re-slots the roadmap
to fold the tier into the not-yet-shipped Plan B (mig 028) + M3-frontend.
Session 0072 (spec). Revises docs/design/multi-project-spec.md §22.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,543 @@
|
||||
# Draft spec — §22 refactor: three tiers (deployment → project → RFC collection)
|
||||
|
||||
> Status: **draft for review.** Binding voice, but not yet merged into
|
||||
> `SPEC.md`. This doc **revises the §22 model** in
|
||||
> [`multi-project-spec.md`](./multi-project-spec.md) from two tiers
|
||||
> (deployment → project, where a "project" *is* a corpus) to **three tiers**
|
||||
> (deployment → project → RFC collection, where the *collection* is the
|
||||
> corpus). It supersedes the conflicting parts of that draft; the parts it does
|
||||
> not touch (the registry-is-git-truth stance, the cache mirror, visibility
|
||||
> semantics, the `type`/`initial_state`/`unreviewed` machinery) carry over
|
||||
> unchanged, re-homed onto the collection. Rationale and the decisions behind
|
||||
> this live in [`multi-project.md`](./multi-project.md) and session 0072.
|
||||
> Target release: folded into the not-yet-shipped §22 work (Plan B + M3-frontend),
|
||||
> a pre-1.0 minor carrying breaking changes with upgrade steps (§20.2).
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this revision
|
||||
|
||||
The original §22 (`multi-project-spec.md`) gave a deployment **N projects**,
|
||||
where each project *was* a single typed corpus: one content repo, one `type`,
|
||||
one slug namespace, one member roster. That conflates two responsibilities —
|
||||
**organizational grouping** and **a typed body of entries** — into one noun.
|
||||
|
||||
This revision splits them. A **project** becomes a pure grouping tier (settings
|
||||
+ one content repo) that holds **any number of RFC collections**; an **RFC
|
||||
collection** is the typed corpus the original §22 called a "project." Everything
|
||||
the original §22 said about a corpus (type, slug namespace, catalog, philosophy,
|
||||
landing state, review flag, membership) moves down one level to the collection;
|
||||
the deployment level is unchanged.
|
||||
|
||||
The timing is favorable: the breaking slug-PK rebuild (Plan B / migration 028)
|
||||
and the public `/p/<id>/` routing (M3-frontend) **have not shipped**, so this is
|
||||
a revision of in-flight *designs*, not a rework of shipped surfaces (§7).
|
||||
|
||||
---
|
||||
|
||||
# Part A — The three-tier model
|
||||
|
||||
## A.1 The tiers
|
||||
|
||||
```
|
||||
deployment (= "global" in the UI) one Gitea org, one bot, one account
|
||||
│ system, one inbox, one running process;
|
||||
│ the surface a visitor first lands on.
|
||||
└─ project ◀ NEW a named grouping + project settings;
|
||||
│ owns exactly ONE content repo. No type.
|
||||
└─ RFC collection a typed corpus: type, slug namespace,
|
||||
│ catalog, philosophy, initial_state,
|
||||
│ unreviewed flag, members. (= what the
|
||||
│ original §22 called a "project".)
|
||||
└─ entry an RFC / spec / feature, identified by
|
||||
its slug within the collection.
|
||||
```
|
||||
|
||||
- **Deployment / "global."** Unchanged top tier. Owns accounts, the §6
|
||||
admission gate, the §15 inbox, the §1 bot, and the deployment landing
|
||||
directory. Its management surface is **projects + global settings**.
|
||||
- **Project** *(new)*. Belongs to exactly one deployment; never moves. Owns one
|
||||
content repo (§A.2) and carries project settings (name, tagline, theme,
|
||||
visibility, model universe). Has **no `type`** of its own. Its management
|
||||
surface is **RFC collections + project settings**.
|
||||
- **RFC collection.** A typed subfolder of its project's content repo (§A.2).
|
||||
Carries everything the original §22 pinned on a "project": the immutable
|
||||
`type` (§22.4a `document` | `specification` | `bdd` | …), the per-collection
|
||||
slug namespace (§A.3), `initial_state` (§22.4b), the `unreviewed` flag
|
||||
(§22.4c), catalog, philosophy. This is "closest to what OHM originally
|
||||
managed as a single corpus."
|
||||
- **Entry.** Unchanged (§2). Identified by its slug **within its collection**.
|
||||
|
||||
A collection belongs to exactly one project; a project to exactly one
|
||||
deployment. Isolation (§22.1) now holds at the **collection** grain: an RFC,
|
||||
branch, thread, star, or watch belongs to exactly one collection.
|
||||
|
||||
## A.2 Storage and git-truth
|
||||
|
||||
Two git sources, both read by the bot, both mirrored into cache tables the §4
|
||||
way:
|
||||
|
||||
1. **The registry repo** (`projects.yaml`, located by `REGISTRY_REPO`, §22.2)
|
||||
declares **projects** — `id`, `name`, `content_repo`, settings, `visibility`,
|
||||
`theme`, `enabled_models`. `content_repo` moves **up** from the collection
|
||||
(original §22) to the project: a project owns exactly one content repo.
|
||||
|
||||
2. **Each project's content repo** declares its **collections** as typed
|
||||
subfolders, each carrying a **`.collection.yaml` manifest** (the collection's
|
||||
`type`, `visibility`, `initial_state`). The registry mirror walks the content
|
||||
repo and reads these manifests, so collection configuration is git-truth and
|
||||
survives a cache rebuild — exactly as entry frontmatter does.
|
||||
|
||||
```yaml
|
||||
# projects.yaml (registry repo root)
|
||||
deployment:
|
||||
name: Wiggleverse
|
||||
tagline: ...
|
||||
projects:
|
||||
- id: ohm
|
||||
name: Open Human Model
|
||||
content_repo: ohm-content # ONE repo; collections live inside it
|
||||
visibility: public # gated | public | unlisted (§22.5)
|
||||
theme: { accent: "#5b5bd6" }
|
||||
enabled_models: [claude, gemini]
|
||||
```
|
||||
|
||||
```yaml
|
||||
# ohm-content/features/.collection.yaml (one per collection subfolder)
|
||||
type: bdd # document | specification | bdd — immutable
|
||||
visibility: gated # defaults to the project's, may narrow
|
||||
initial_state: active # defaults from type (§22.4b)
|
||||
name: Feature scenarios
|
||||
```
|
||||
|
||||
```
|
||||
ohm-content/
|
||||
model/
|
||||
.collection.yaml # type: document
|
||||
intro.md
|
||||
specs/
|
||||
.collection.yaml # type: specification
|
||||
runtime.md
|
||||
features/
|
||||
.collection.yaml # type: bdd
|
||||
login.md
|
||||
```
|
||||
|
||||
**Creation is in-app, wrapping a bot commit, at both tiers:**
|
||||
|
||||
- **+ New project** (a global Owner action): the bot **creates a Gitea content
|
||||
repo** under the deployment org, **commits a project entry** to
|
||||
`projects.yaml`, and the mirror picks it up.
|
||||
- **+ New collection** (a project Owner / RFC Contributor-with-create action):
|
||||
the bot **commits a new subfolder + `.collection.yaml`** to the project's
|
||||
content repo; the mirror picks it up.
|
||||
|
||||
The in-app button is a thin convenience over a git write; nothing becomes app
|
||||
state that git cannot rebuild. `projects` and `collections` cache rows are never
|
||||
written from user actions directly — they flow from the mirror only (§22.2).
|
||||
**Membership** (§B-roles) remains app state, as `rfc_collaborators` always has
|
||||
been — it churns at user speed and is not document state.
|
||||
|
||||
## A.3 Identity and routing
|
||||
|
||||
The slug is unique **within a collection**; the fully-qualified identity is
|
||||
`(project, collection, slug)`. `model/intro` and `specs/intro` coexist. No type
|
||||
prefix, no numbers (the §22.4 retirement of `RFC-NNNN` allocation stands;
|
||||
legacy `id` frontmatter remains a frozen, non-identity display label).
|
||||
|
||||
Canonical route:
|
||||
|
||||
```
|
||||
/p/<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 light: no `/p/<id>/` URL ever shipped (M3-frontend is unmerged).
|
||||
The only live legacy URLs are the pre-multi-project `/rfc/<slug>` /
|
||||
`/proposals/<n>`, which **308-redirect** to the migration's default project →
|
||||
default collection (§A.6).
|
||||
|
||||
---
|
||||
|
||||
# Part B — Roles and authorization
|
||||
|
||||
## B.1 One role vocabulary, attached at a scope
|
||||
|
||||
There is **one role enum — `{owner, contributor}`** — displayed as **Owner**
|
||||
and **RFC Contributor**. A grant *attaches that role at a scope*: **global**,
|
||||
**project**, or **collection**. "Owner at all levels, RFC Contributor at all
|
||||
levels" is therefore literal — the same two words at every tier, not a fresh
|
||||
pair invented per tier.
|
||||
|
||||
| Role | Capabilities within its scope's subtree |
|
||||
|---|---|
|
||||
| **Owner** | Superuser: manage settings and membership; create child projects/collections; act on any entry (merge on behalf, graduate, mark-reviewed, withdraw/reopen, set branch visibility). |
|
||||
| **RFC Contributor** | Propose entries, create branches, open PRs, claim unclaimed super-drafts, participate in discussion. At **project** (or global) scope this additionally includes **creating collections** in that project — the "anyone at the project level with permission to create a collection" affordance. (A *collection*-scope grant cannot create sibling collections; creating one is a project-level action.) |
|
||||
|
||||
This **reconciles** the role names the prior drafts accumulated — they were
|
||||
different words for the same idea:
|
||||
|
||||
| Prior spec term | Tier it lived at | Unified role |
|
||||
|---|---|---|
|
||||
| deployment `owner` / `admin` (§6.1) | global | **Owner** (global) |
|
||||
| deployment `contributor` (§6.1) | global | **RFC Contributor** (global) |
|
||||
| `project_admin` (M2 §22.6) | the corpus → now the **collection** | **Owner** (collection) |
|
||||
| `project_contributor` (M2 §22.6) | the corpus → now the **collection** | **RFC Contributor** (collection) |
|
||||
| `project_viewer` (M2 §22.6) | the corpus | *deferred* (read-only grant; not one of this pass's two) |
|
||||
|
||||
> **Scope-narrowing, not renaming.** Collapsing `owner`/`admin` into one
|
||||
> **Owner** and dropping `viewer` for this pass are deliberate deferrals (the
|
||||
> launch ask: "we don't need to get all permissions right yet"). When they
|
||||
> return they **re-split out of** Owner / add a tier; they are not aliases of
|
||||
> the unified roles. The richer set is future work.
|
||||
|
||||
## B.2 Inheritance and resolution
|
||||
|
||||
Grants inherit **downward**, are **additive**, and admit **no negative
|
||||
override**:
|
||||
|
||||
- A grant at **global** covers every project and collection in the deployment.
|
||||
- A grant at **project** covers every collection in that project.
|
||||
- A grant at **collection** covers just that collection.
|
||||
- You **cannot** grant a role at a parent scope and revoke it at a child (the
|
||||
launch ask: "too complex"). Resolution never subtracts a parent grant.
|
||||
|
||||
Effective authority on an entry generalizes the §22.7 most-permissive union
|
||||
from three layers to four (global → project → collection → per-entry):
|
||||
|
||||
```
|
||||
effective authority on an entry =
|
||||
global role (users.role)
|
||||
∪ project role (membership at the entry's project)
|
||||
∪ collection role (membership at the entry's collection)
|
||||
∪ per-entry authority (owners / arbiters / rfc_collaborators — §6.3, §12)
|
||||
then minus §6.2 write-mute and §22.5 visibility (subtractive, as today)
|
||||
```
|
||||
|
||||
**Per-entry authority is a distinct, finer layer — not a synonym.** `owners` /
|
||||
`arbiters` / `rfc_collaborators` apply to *one specific entry* (§6.3, §12); the
|
||||
three named scopes apply to a *subtree*. Per-entry authority is unchanged and
|
||||
sits beneath collection in the union. `arbiter` is narrower than Owner (one
|
||||
entry, not a subtree) and stays distinct.
|
||||
|
||||
## B.3 Schema impact
|
||||
|
||||
- `users.role` continues to carry the **global** role (deployment owner /
|
||||
contributor).
|
||||
- M2's `project_members(project_id, role)` rows were attached at what we now
|
||||
call the **collection**. They generalize into a single polymorphic
|
||||
**`memberships(scope_type ∈ {project, collection}, scope_id, user_id, role,
|
||||
granted_by, granted_at)`** table; the M2 rows migrate to
|
||||
`scope_type='collection'`. The **project** tier gets the same two roles,
|
||||
freshly grantable.
|
||||
- The M2 three-role enum (`viewer`/`contributor`/`admin`) collapses to
|
||||
`{owner, contributor}`: `project_admin → owner`, `project_contributor →
|
||||
contributor`, `project_viewer →` a read grant (no write) folded into
|
||||
visibility, not a membership role this pass.
|
||||
|
||||
---
|
||||
|
||||
# Part C — Behavioral scenarios (BDD)
|
||||
|
||||
> These Gherkin scenarios are the behavioral spec for **role usage**,
|
||||
> **invitation**, and **empty-state** experiences. They attach to the rewritten
|
||||
> §22 as **§22.6a (role & invitation scenarios)**. They are written so they can
|
||||
> *also* seed a `bdd`-type collection later (the framework dogfooding its own
|
||||
> model). "Owner"/"RFC Contributor" are the unified roles (§B.1); a *scope* in
|
||||
> the `Given` is global / project / collection.
|
||||
|
||||
## C.1 Role usage — inheritance and the most-permissive union
|
||||
|
||||
```gherkin
|
||||
Feature: Scope roles grant authority over a subtree
|
||||
As a member of the deployment
|
||||
I want a role granted at one tier to apply to everything beneath it
|
||||
So that I can be invited once and work across the right set of collections
|
||||
|
||||
Background:
|
||||
Given a deployment with a project "ohm"
|
||||
And "ohm" owns collections "model" (document) and "features" (bdd)
|
||||
|
||||
Scenario: Collection RFC Contributor may propose only in that collection
|
||||
Given "ada" is RFC Contributor at collection "ohm/model"
|
||||
When "ada" opens the propose form in "ohm/model"
|
||||
Then she may submit a new entry
|
||||
When "ada" opens "ohm/features"
|
||||
Then she sees it read-only and the propose action is not offered
|
||||
|
||||
Scenario: Project RFC Contributor may propose in every collection of the project
|
||||
Given "ben" is RFC Contributor at project "ohm"
|
||||
Then "ben" may propose in "ohm/model"
|
||||
And "ben" may propose in "ohm/features"
|
||||
And a collection added to "ohm" later is writable by "ben" with no new grant
|
||||
|
||||
Scenario: Global RFC Contributor may propose in every collection of every project
|
||||
Given a second project "acme" with collection "acme/specs"
|
||||
And "cleo" is RFC Contributor at global scope
|
||||
Then "cleo" may propose in "ohm/model" and "acme/specs"
|
||||
|
||||
Scenario: Collection Owner administers one collection only
|
||||
Given "dan" is Owner at collection "ohm/features"
|
||||
Then "dan" may graduate, mark-reviewed, and manage membership in "ohm/features"
|
||||
But "dan" may not change "ohm" project settings
|
||||
And "dan" may not act on entries in "ohm/model"
|
||||
|
||||
Scenario: Project Owner administers all collections and may create more
|
||||
Given "eve" is Owner at project "ohm"
|
||||
Then "eve" may manage membership in "ohm/model" and "ohm/features"
|
||||
And "eve" may edit "ohm" project settings
|
||||
And "eve" may create a new collection in "ohm"
|
||||
|
||||
Scenario: Most-permissive union — the higher grant wins
|
||||
Given "fay" is RFC Contributor at collection "ohm/model"
|
||||
And "fay" is Owner at project "ohm"
|
||||
Then "fay" acts as Owner in "ohm/model"
|
||||
|
||||
Scenario: No negative override — a child cannot subtract a parent grant
|
||||
Given "gil" is RFC Contributor at project "ohm"
|
||||
Then there is no control to remove "gil" from "ohm/model" while keeping the project grant
|
||||
And "gil" can propose in "ohm/model"
|
||||
|
||||
Scenario: A granted account with no scope role sees only public content
|
||||
Given "hana" has a granted deployment account but no global, project, or collection role
|
||||
Then "hana" may read public collections under the §6.1 anonymous-read contract
|
||||
But "hana" is not offered the propose action anywhere
|
||||
And gated projects and collections do not appear for her
|
||||
```
|
||||
|
||||
## C.2 Invitation — who may invite whom, at which scope
|
||||
|
||||
```gherkin
|
||||
Feature: Inviting users to a scope role
|
||||
As an Owner of a scope
|
||||
I want to grant Owner or RFC Contributor at my scope or any scope beneath it
|
||||
So that collaborators get exactly the reach they need
|
||||
|
||||
Scenario: Project Owner invites at project scope (covers all collections)
|
||||
Given "eve" is Owner at project "ohm"
|
||||
When "eve" invites "ivy" as RFC Contributor at project "ohm"
|
||||
Then a membership row is written at scope project "ohm"
|
||||
And "ivy" receives a §15 notification naming the project and role
|
||||
And "ivy" may propose in every collection of "ohm"
|
||||
|
||||
Scenario: Owner invites at a specific collection
|
||||
When "eve" invites "jo" as RFC Contributor at collection "ohm/features"
|
||||
Then a membership row is written at scope collection "ohm/features"
|
||||
And "jo" may propose in "ohm/features" but not "ohm/model"
|
||||
|
||||
Scenario: Invitation reach is bounded by the inviter's scope
|
||||
Given "dan" is Owner at collection "ohm/features"
|
||||
Then "dan" may invite users to roles in "ohm/features"
|
||||
But "dan" is not offered the control to invite at project "ohm" or global scope
|
||||
|
||||
Scenario: RFC Contributors do not manage membership
|
||||
Given "ben" is RFC Contributor at project "ohm"
|
||||
Then "ben" may propose and create collections in "ohm"
|
||||
But "ben" is not offered any invite control (membership is an Owner capability)
|
||||
|
||||
Scenario: The invite UI offers no grant-at-parent-revoke-at-child option
|
||||
Given "eve" is Owner at project "ohm"
|
||||
When "eve" opens the invite control for "ivy" at project "ohm"
|
||||
Then she may choose role Owner or RFC Contributor and scope project or a single collection
|
||||
But there is no option to grant at "ohm" and exclude a child collection
|
||||
|
||||
Scenario: Re-inviting at a broader scope supersedes the narrower grant
|
||||
Given "jo" is RFC Contributor at collection "ohm/features"
|
||||
When "eve" invites "jo" as RFC Contributor at project "ohm"
|
||||
Then "jo" has the role across all of "ohm"
|
||||
And the redundant collection-scope row is removed or shown as subsumed
|
||||
|
||||
Scenario: A pending deployment account cannot be granted write
|
||||
Given "kim" has permission_state "pending" at the deployment
|
||||
When "eve" invites "kim" as RFC Contributor at project "ohm"
|
||||
Then the grant is recorded but confers no write capability until "kim" is granted at the deployment (§6)
|
||||
```
|
||||
|
||||
## C.3 Empty-state experiences
|
||||
|
||||
```gherkin
|
||||
Feature: Empty states at each tier
|
||||
As a viewer of a tier with nothing in it yet
|
||||
I want a clear, role-appropriate empty state
|
||||
So that I know whether there is an action to take or simply nothing to see
|
||||
|
||||
Scenario: Global directory with no projects — Owner
|
||||
Given a deployment with no projects
|
||||
And "root" is Owner at global scope
|
||||
When "root" lands on "/"
|
||||
Then she sees an empty directory with a "Create your first project" call to action
|
||||
|
||||
Scenario: Global directory with no visible projects — non-owner
|
||||
Given a deployment whose only projects are gated
|
||||
And "vee" is a granted account with no roles
|
||||
When "vee" lands on "/"
|
||||
Then she sees an empty directory with no create action
|
||||
And a note that there is nothing shared with her yet
|
||||
|
||||
Scenario: Project with no collections — project Owner
|
||||
Given project "ohm" with no collections
|
||||
And "eve" is Owner at project "ohm"
|
||||
When "eve" lands on "/p/ohm/"
|
||||
Then she sees an empty collection directory with a "Create your first collection" call to action
|
||||
And the action lets her choose a type and subfolder
|
||||
|
||||
Scenario: Project with no collections — RFC Contributor without create rights
|
||||
Given project "ohm" with no collections
|
||||
And "ben" is RFC Contributor at collection scope elsewhere only
|
||||
When "ben" lands on "/p/ohm/"
|
||||
Then he sees an empty collection directory with no create action
|
||||
|
||||
Scenario: Collection with no entries — a contributor
|
||||
Given collection "ohm/model" with no entries
|
||||
And "ada" is RFC Contributor at collection "ohm/model"
|
||||
When "ada" lands on "/p/ohm/c/model/"
|
||||
Then she sees an empty catalog with a "Propose the first entry" call to action
|
||||
|
||||
Scenario: Collection with no entries — an anonymous reader
|
||||
Given a public collection "ohm/model" with no entries
|
||||
When an anonymous visitor lands on "/p/ohm/c/model/"
|
||||
Then they see an empty catalog with no propose action and a sign-in prompt
|
||||
|
||||
Scenario: Single-collection project skips the directory
|
||||
Given project "ohm" with exactly one visible collection "model"
|
||||
When a visitor lands on "/p/ohm/"
|
||||
Then they are redirected to "/p/ohm/c/model/"
|
||||
|
||||
Scenario: Single-project deployment skips the directory
|
||||
Given a deployment with exactly one visible project "ohm"
|
||||
When a visitor lands on "/"
|
||||
Then they are redirected to "/p/ohm/"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
# Part D — Amendments to the original §22 draft
|
||||
|
||||
Applied in place when §22 is rewritten; listed here as the change surface.
|
||||
|
||||
- **§22 preamble / §22.1.** "A deployment hosts N projects, each a corpus" →
|
||||
"a deployment hosts N **projects**, each owning one content repo and holding
|
||||
N **RFC collections**, each collection a typed corpus." Isolation moves to the
|
||||
collection grain.
|
||||
- **§22.2 Registry.** `projects.yaml` declares projects with one `content_repo`
|
||||
each (no per-collection `content_repo`). New: collections are declared by
|
||||
`.collection.yaml` manifests inside the content repo; the mirror reads them.
|
||||
In-app create-project / create-collection actions wrap bot commits.
|
||||
- **§22.3 Content repos.** "One per project" (not per collection); collections
|
||||
are subfolders within it.
|
||||
- **§22.4 / §22.4a-c.** Slug is unique **per collection**. `type`,
|
||||
`initial_state`, and `unreviewed` are **collection** properties (re-homed from
|
||||
"project"). Unchanged otherwise.
|
||||
- **§22.5 Visibility.** Applies at **both** project and collection. A collection
|
||||
defaults to its project's visibility and may narrow it; reading/writing a
|
||||
collection requires passing both gates.
|
||||
- **§22.6 Membership and roles → the unified model (Part B).** Replace the three
|
||||
`project_*` roles with `{owner, contributor}` at `{global, project,
|
||||
collection}` via a polymorphic `memberships` table. Add **§22.6a** = the
|
||||
Part C scenarios.
|
||||
- **§22.7 Composition.** Four-layer most-permissive union (global → project →
|
||||
collection → per-entry); no negative override.
|
||||
- **§22.9 / §22.10 Branding & routing.** Routes gain the collection segment:
|
||||
`/p/<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 decision): **fold the third tier into the not-yet-shipped
|
||||
work** rather than ship two-tier and migrate again. M1, M2, and M3-backend
|
||||
Plan A are additive and already merged (v0.39.0); they keep running. The tier
|
||||
goes in at the **first breaking migration (Plan B / 028)** and the **first
|
||||
public routing (M3-frontend)**, before either ships.
|
||||
|
||||
- **Landed, unchanged (v0.39.0):** M1 (additive `project_id` spine, mig 026),
|
||||
M2 (authz resolver), M3-backend Plan A (registry mirror, APIs, propose /
|
||||
mark-reviewed, mig 027). These read fine under the new model: today's single
|
||||
`projects` row is re-read as the **default collection** after the §E migration
|
||||
relabels it.
|
||||
|
||||
- **M3-backend Plan B → "insert the project tier + re-key to per-collection"**
|
||||
(revised). Migration 028 now: (1) add the `projects` (grouping) table with
|
||||
`content_repo`; (2) rename the landed `projects` table to `collections`, drop
|
||||
its `content_repo`, add `project_id` + `subfolder`; (3) re-key every
|
||||
entry-scoped `project_id` → `collection_id`; (4) the slug-PK rebuild targets
|
||||
`(collection_id, slug)`; (5) generalize `project_members` → the polymorphic
|
||||
`memberships` table and collapse the role enum (§B.3); (6) the §22.13 default
|
||||
project wraps the default collection (§A.6). One breaking migration, one slug
|
||||
rebuild.
|
||||
|
||||
- **M3-backend Plan B+ — manifests & in-app create.** Teach the mirror to read
|
||||
`.collection.yaml`; add the bot-commit-wrapped **create-project** and
|
||||
**create-collection** endpoints; mirror new collections.
|
||||
|
||||
- **M3-frontend → three-tier routing.** `/p/<project>/c/<collection>/e/<slug>`;
|
||||
the deployment directory at `/`, the project (collection) directory at
|
||||
`/p/<project>/`; the single-project / single-collection redirects (§A.3); the
|
||||
308s off `/rfc/<slug>`; runtime branding at both project and collection.
|
||||
|
||||
- **M4 — second-collection + second-project acceptance.** End-to-end pass: a
|
||||
second collection in a project, and a second project, each with disjoint
|
||||
membership, full propose→graduate lifecycle under the per-collection slug
|
||||
namespace.
|
||||
|
||||
- **M5 — type modules (unchanged in intent).** Per-type frontmatter + surfaces,
|
||||
now selected on the **collection's** `type`.
|
||||
|
||||
- **M6 — membership lifecycle.** Invite + request-to-join at all three scopes
|
||||
(Part C.2); the cross-collection inbox; the roster admin surface. The Part C
|
||||
scenarios are this slice's acceptance tests.
|
||||
|
||||
- **M7 — hardening + SPEC merge.** Per-collection `enabled_models`; the registry
|
||||
+ manifest format in `docs/DEPLOYMENTS.md`; two-project / multi-collection e2e;
|
||||
the §20.4 changelog + upgrade-steps; the SPEC merge (Part A applied, Part D
|
||||
amendments in place).
|
||||
|
||||
## E.1 (= §A.6) Migration — the default project and default collection
|
||||
|
||||
A pre-three-tier deployment (already on v0.39.0's single `projects` row, or
|
||||
pre-multi-project) is migrated so it keeps running:
|
||||
|
||||
1. The landed single `projects` row becomes the **default collection**
|
||||
(`type=document`, its `initial_state`/`unreviewed` preserved).
|
||||
2. A **default project** is generated to wrap it: `content_repo` moves from the
|
||||
(old project / now collection) onto the project; `id` is the config-derived
|
||||
slug already re-stamped in §22.13 step 1 (v0.39.0).
|
||||
3. Every entry-scoped `project_id` is re-keyed to the default `collection_id`.
|
||||
4. M2's `project_members` rows migrate to `memberships(scope_type='collection')`
|
||||
on the default collection, role-collapsed (§B.3).
|
||||
5. `/rfc/<slug>` and `/proposals/<n>` 308-redirect to
|
||||
`/p/<default-project>/c/<default-collection>/…`.
|
||||
|
||||
Until a second project or collection is added, the deployment is functionally
|
||||
identical to before, with two extra path segments. This is the §20.4
|
||||
upgrade-steps content for the release.
|
||||
|
||||
## E.2 Scope of the first implementation pass
|
||||
|
||||
Per the launch ask — "we don't need to get all permissions right yet, just have
|
||||
Owner at all levels, and RFC Contributor at the global, project, and RFC
|
||||
collection level" — the **role surface** this pass implements is exactly
|
||||
`{owner, contributor}` × `{global, project, collection}` (Part B), plus the
|
||||
unchanged per-entry layer. `viewer`, the owner/admin split, request-to-join
|
||||
nuances, and per-type role labels are deferred (§B.1 note).
|
||||
Reference in New Issue
Block a user