9c0e3b60ac
Re-checked code vs the stale memory: migration 028 (slug PK -> (project_id, slug)), v0.35.0 /p/<project>/ routing, and v0.37/0.38 per-project read+propose are all shipped to main. The 'fold into not-yet-shipped Plan B + M3-frontend' premise is false. Neutralize the wrong claims in §0/§A.3 and flag Part E's sequencing as pending re-decision; structural model (Parts A-D) unaffected. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
568 lines
28 KiB
Markdown
568 lines
28 KiB
Markdown
# 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) is **pending re-decision** with correct facts; 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 the
|
||
corrected Part E for the real migration cost (a new migration 029 + a breaking
|
||
URL change with 308s), pending re-decision.
|
||
|
||
---
|
||
|
||
# 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.
|
||
|
||
## 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)
|
||
|
||
> ⚠️ **PENDING RE-DECISION (session 0072).** The strategy below assumed Plan B
|
||
> and M3-frontend were unshipped. They are shipped (v0.35.0–0.39.0). The "fold
|
||
> into not-yet-shipped work" framing is void; the actual work is a **new
|
||
> migration 029** that evolves the shipped two-tier schema to three tiers, plus
|
||
> a **breaking `/p/<project>/e/<slug>` → `/p/<project>/c/<collection>/e/<slug>`
|
||
> URL change with 308s**. The two live mapping options (relabel today's
|
||
> project → collection + insert a group tier above, vs. add a collection
|
||
> sub-grain beneath today's project) are being put back to the operator. The
|
||
> text below is retained only as the structural target, not the sequencing.
|
||
|
||
Strategy (ORIGINAL, premise now false): 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).
|