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>
28 KiB
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 inmulti-project-spec.mdfrom 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, thetype/initial_state/unreviewedmachinery) carry over unchanged, re-homed onto the collection. Rationale and the decisions behind this live inmulti-project.mdand 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
typeof 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.4adocument|specification|bdd| …), the per-collection slug namespace (§A.3),initial_state(§22.4b), theunreviewedflag (§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:
-
The registry repo (
projects.yaml, located byREGISTRY_REPO, §22.2) declares projects —id,name,content_repo, settings,visibility,theme,enabled_models.content_repomoves up from the collection (original §22) to the project: a project owns exactly one content repo. -
Each project's content repo declares its collections as typed subfolders, each carrying a
.collection.yamlmanifest (the collection'stype,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.
# 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]
# 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.yamlto 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/admininto one Owner and droppingviewerfor 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.rolecontinues 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 polymorphicmemberships(scope_type ∈ {project, collection}, scope_id, user_id, role, granted_by, granted_at)table; the M2 rows migrate toscope_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 theGivenis global / project / collection.
C.1 Role usage — inheritance and the most-permissive union
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
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
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.yamldeclares projects with onecontent_repoeach (no per-collectioncontent_repo). New: collections are declared by.collection.yamlmanifests 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, andunreviewedare 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 polymorphicmembershipstable. 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/deploymentlists visible projects; addGET /api/projects/:id(lists visible collections + project settings) andGET /api/projects/:id/collections/:cid(collection settings incl.type). - §22.11 Notifications / §22.13 migration / §5 amendments.
project_idbecomescollection_idon every entry-scoped row (the corpus grain is now the collection); a separateproject_idexists only on thecollectionstable 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_idspine, 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 singleprojectsrow 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 withcontent_repo; (2) rename the landedprojectstable tocollections, drop itscontent_repo, addproject_id+subfolder; (3) re-key every entry-scopedproject_id→collection_id; (4) the slug-PK rebuild targets(collection_id, slug); (5) generalizeproject_members→ the polymorphicmembershipstable 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).
- manifest format in
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:
- The landed single
projectsrow becomes the default collection (type=document, itsinitial_state/unreviewedpreserved). - A default project is generated to wrap it:
content_repomoves from the (old project / now collection) onto the project;idis the config-derived slug already re-stamped in §22.13 step 1 (v0.39.0). - Every entry-scoped
project_idis re-keyed to the defaultcollection_id. - M2's
project_membersrows migrate tomemberships(scope_type='collection')on the default collection, role-collapsed (§B.3). /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).