§22 S6: merge three-tier model into SPEC.md + registry format in DEPLOYMENTS

Write the canonical §22 (deployment → project → RFC collection) into the
binding spec, applying Part A of the design doc and the Part D amendments in
place. Lands the S3 keystone reinterpretation of §B.1/§B.3: a plain granted
account is a granted account, not a global write role; "global RFC Contributor"
is an explicit memberships(scope_type='global') grant; the implicit-public
write baseline is grandfathered onto the migration-seeded default collection
only (N=1 preserved).

- SPEC.md §22.1–§22.14: tiers + isolation, the registry + .collection.yaml
  manifests, content-repo-per-project, per-collection slug identity, collection
  type/initial_state/unreviewed, two-tier visibility (narrow-only), the unified
  {owner, contributor} role vocabulary at {global, project, collection}, the
  four-layer most-permissive union, discovery/joining (invite + request-to-join),
  runtime branding, /p/<project>/c/<collection>/ routing, one inbox,
  per-collection model universe, the default-project+collection migration, and
  §22.14 consolidating the §§1–21 amendments.
- Forward-pointer amendment notes (house style) at §1, §2, §5, §6 routing
  readers to §22.
- docs/DEPLOYMENTS.md: the registry (REGISTRY_REPO / projects.yaml) and
  .collection.yaml manifest formats, the N=1 default-project+collection upgrade,
  and in-app create-project/collection.

Per docs/design/2026-06-05-three-tier-projects-collections.md Parts A/B/D/E (S6).
Docs-only; no code or migration. Releases as part of v0.45.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-06 01:21:39 -07:00
parent b0737380cd
commit 26f3680197
2 changed files with 618 additions and 0 deletions
+525
View File
@@ -65,6 +65,18 @@ in `rfcs/`" is the whole mental model a new deployer needs.
> and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The > and its `wiggleverse/rfc-0001-human` repo archived (see §13.6). The
> decision record is OHM ROADMAP #36. > decision record is OHM ROADMAP #36.
> **Three-tier change (v0.45.0 — supersedes the single-corpus topology;
> see §22).** A deployment is no longer one corpus in one repo. It has a
> **registry** (§22.2) naming N **projects**, each owning **one content
> repo** (§22.3) that holds N typed **RFC collections** as subfolders.
> "This single repository is its content repository" now reads "each
> *project* names one content repository; the deployment's registry lists
> them." The bot and app-owned-authorization paragraphs below are
> unchanged and now read **org-wide** across every content repo and the
> registry repo. The single-corpus deployment is the N=1 case and keeps
> running unchanged via a generated default project + default collection
> (§22.13). §22 is the binding model.
All Git operations on the meta repository are performed by a single **bot All Git operations on the meta repository are performed by a single **bot
service account** in Gitea. Real human users do not have meaningful Gitea service account** in Gitea. Real human users do not have meaningful Gitea
permissions on the repo itself; their accounts exist for OAuth identity permissions on the repo itself; their accounts exist for OAuth identity
@@ -107,6 +119,18 @@ That's the entirety of the meta repo. App-level permission state, user
accounts, chat history, audit logs, and branch visibility grants do **not** accounts, chat history, audit logs, and branch visibility grants do **not**
live in the meta repo — they live in the app database (see §5). live in the meta repo — they live in the app database (see §5).
> **Three-tier amendment (v0.45.0 — see §22).** Slugs are unique **per
> collection** (§22.4): `model/intro` and `specs/intro` coexist. The entry
> frontmatter schema is **type-dependent** on the collection's `type`
> (§22.4a) — `document` keeps the fields below; `specification` and `bdd`
> add their type metadata. The §2.3 `RFC-NNNN` `max+1` allocation is
> **removed** (the slug is the identity); pre-change `id` values survive as
> frozen legacy labels. New `active`-entry frontmatter: `unreviewed` (bool)
> and the `reviewed_at` / `reviewed_by` provenance pair (§22.4c).
> Collection configuration (`type`, `visibility`, `initial_state`) lives in
> a `.collection.yaml` manifest in the content repo, mirrored like entry
> frontmatter (§22.2).
### 2.1 Entry file format ### 2.1 Entry file format
```markdown ```markdown
@@ -298,6 +322,18 @@ the natural path is SQLite FTS5 indexed off the reconciler.
These are the tables that are app-owned (not cached from Gitea). Names These are the tables that are app-owned (not cached from Gitea). Names
and exact columns are illustrative; the implementing session can adjust. and exact columns are illustrative; the implementing session can adjust.
> **Three-tier amendment (v0.45.0 — see §22).** Every entry-scoped table
> below carries the corpus grain, which is now the **collection**: the
> column is **`collection_id`** (not `project_id`), and `cached_rfcs` is
> keyed `(collection_id, slug)`. A denormalized `project_id` rides the
> high-churn cache/notification rows for filtering. New app-owned tables:
> **`projects`** (one content repo, project settings), **`collections`**
> (the immutable `type`, `subfolder`, `initial_state`, `visibility`,
> mirrored from `.collection.yaml`, §22.2), and **`memberships`**
> (`scope_type ∈ {global, project, collection}`, the unified
> `{owner, contributor}` roles — §22.6, replacing M2's `project_members`).
> `users.role` is the **deployment** admission tier only (§22.7).
- `users``id`, `email`, `display_name`, `gitea_login`, `role` (one - `users``id`, `email`, `display_name`, `gitea_login`, `role` (one
of `owner` / `admin` / `contributor`), `muted` (bool — the §6.2 of `owner` / `admin` / `contributor`), `muted` (bool — the §6.2
app-wide write-mute, distinct from the per-RFC and per-user app-wide write-mute, distinct from the per-RFC and per-user
@@ -435,6 +471,20 @@ merge with no data movement.
Authorization is owned by the app. Gitea sees only the bot account. Authorization is owned by the app. Gitea sees only the bot account.
> **Three-tier amendment (v0.45.0 — see §22.6–§22.7).** The deployment
> roles described in this section are the **global** tier of a four-layer
> most-permissive union (global → project → collection → per-entry).
> Scope roles use one vocabulary — **Owner** and **RFC Contributor**
> attached at `{global, project, collection}` via the `memberships` table
> and inheriting downward with no negative override. A plain granted
> `contributor` account is **not** an implicit global write role: it can
> sign in and read, but writing an explicitly-created collection requires
> an explicit `memberships` grant. The one carve-out is the N=1
> default-collection baseline, where the pre-multi-project implicit-public
> write capability is grandfathered (§22.6 keystone note). The §6.3
> per-RFC delegated-authority idea is now **Owner** at project/collection
> scope, sitting above per-entry authority in the union.
Authentication has three paths, in the order a visitor encounters Authentication has three paths, in the order a visitor encounters
them: them:
@@ -4912,3 +4962,478 @@ existing consenters. The cleanest moment to do this is the next
material privacy-policy revision; the conventions in §21.4 hold material privacy-policy revision; the conventions in §21.4 hold
in the interim. in the interim.
---
## 22. Three tiers: deployment → project → RFC collection
A **deployment** hosts one or more **projects**; each project owns one
content repository and holds one or more **RFC collections**; each
collection is a typed corpus of **entries**. This is the three-tier model
that supersedes the original single-corpus framing of §§121 and the
two-tier (deployment → project) draft that preceded it.
```
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 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.
└─ entry an RFC / spec / feature, identified by
its slug within the collection.
```
Everything §§121 describe about *a corpus* is now *an RFC collection*.
Everything they describe about *a deployment* that is not corpus-specific —
accounts, the §6 admission gate, the §15 inbox, the §1 bot — stays at the
deployment level and is shared. A grouping layer, the **project**, sits
between: it owns the content repo and project-wide settings, and groups the
collections beneath it. The numbered sections that assume a single corpus are
amended in §22.14; **§22 is the binding model they defer to.**
> **Three-tier change (v0.40.0 → v0.45.0 — supersedes the single-corpus and
> the two-tier models).** §1 originally said "this single repository is its
> content repository," and an earlier draft of this section said "a deployment
> hosts N projects, each a corpus." The current model is three tiers: a
> deployment has a **registry** (§22.2) naming N **projects**, each project
> owns **one content repo** (§22.3) holding N **RFC collections** as typed
> subfolders (§22.2). The single-corpus deployment is the **N=1 case** and
> continues to run after migration via a generated default project carrying a
> generated default collection (§22.13); no deployment is forced to adopt more
> than one of either. Where earlier sections say "the meta repo," "the corpus,"
> or "the project," read "the collection's content repo" and "the collection's
> corpus." The slices that delivered this are S1S6 (the design record is
> `docs/design/2026-06-05-three-tier-projects-collections.md`).
### 22.1 The tiers and isolation
One deployment, N projects (N ≥ 1); one project, N collections (N ≥ 1). A
collection belongs to exactly one project; a project to exactly one
deployment; neither moves. **Isolation (§22.5) holds at the collection
grain:** an RFC, branch, thread, star, or watch belongs to exactly one
collection, and no app surface joins across collections except the
per-account ones the deployment owns (the §15 inbox, the §6 account roster,
sign-in).
- **Deployment / "global."** The unchanged top tier. Owns accounts, the §6
admission gate, the §15 inbox, the §1 bot, and the landing directory. Its
management surface is **projects + global settings**.
- **Project.** Belongs to one deployment; never moves. Owns one content repo
(§22.3) 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 (§22.2).
Carries everything the original draft pinned on a "project": the immutable
`type` (§22.4a), the per-collection slug namespace (§22.4), `initial_state`
(§22.4b), the `unreviewed` flag (§22.4c), catalog, philosophy.
- **Entry.** Unchanged (§2). Identified by its slug **within its collection**.
### 22.2 The registry and the collection manifests — git is still truth
Project and collection configuration is declared in git and mirrored into
cache tables (`projects`, `collections`) exactly the way content is mirrored
into `cached_rfcs` (§4). There are **two git sources**, both read by the bot:
1. **The registry repo** declares **projects**. A `projects.yaml` at the root
of a dedicated **registry repo** under the deployment's Gitea org lists each
project's `id`, `name`, `content_repo`, `visibility`, `theme`, and
`enabled_models`. The framework learns the registry repo's location from a
required env var (`REGISTRY_REPO`, the successor to `META_REPO`); the repo's
*name* is the deployment's choice per the separation-of-concerns rule, and
the framework fails loudly at startup if the var is unset. `content_repo`
lives on the **project** (one repo per project), not the collection.
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`, `name`, and optional
`enabled_models`). 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 # deployment display name (replaces VITE_APP_NAME)
tagline: ... # deployment landing deck (§22.10)
projects:
- id: ohm # url-stable slug, unique within the deployment
name: Open Human Model
content_repo: ohm-content # ONE repo under the org; collections live inside it
visibility: public # gated | public | unlisted (§22.5)
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
```
```yaml
# ohm-content/model/.collection.yaml (one per collection subfolder)
type: document # document | specification | bdd — immutable (§22.4a)
visibility: gated # defaults to the project's, may only narrow (§22.5)
initial_state: super-draft # super-draft | active — defaults from type (§22.4b)
name: The Model
# enabled_models: [claude] # optional; narrows the project's universe (§22.12)
```
```
ohm-content/
model/
.collection.yaml # type: document
rfcs/intro.md
specs/
.collection.yaml # type: specification
rfcs/runtime.md
features/
.collection.yaml # type: bdd
rfcs/login.md
```
**Creation is in-app, wrapping a bot commit, at both tiers.** *+ New project*
(a global-Owner action, §22.6) has the bot create a Gitea content repo under
the org and commit a project entry to `projects.yaml`. *+ New collection* (a
project Owner / RFC-Contributor-with-create action) has the bot commit a new
subfolder + `.collection.yaml` to the project's content repo. 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. **Membership**
(§22.6) remains app state, as `rfc_collaborators` always has been — it churns
at user speed and is not document state.
### 22.3 Content repositories — one per project
Each project names one content repo under the deployment's single Gitea org
(convention `<project-id>-content`). Collections are **subfolders** within it
(§22.2). The §1 bot service account operates org-wide across every content
repo and the registry repo; nothing about the bot, the §6 app-owned
authorization, or the "app is the only contribution surface" stance changes.
There are no per-project Gitea orgs and no per-project bot accounts.
### 22.4 The slug namespace is per-collection; the slug is the identity
An entry's slug (§2) is unique **within its collection**: `model/intro` and
`specs/intro` coexist. The fully-qualified identity is `(project, collection,
slug)` — there is no type prefix and **no numeric ID**. The §22.4 retirement
of `RFC-NNNN` allocation stands: the slug is the identity, and graduation
(§13) flips state without allocating a number. The displayed *noun* around a
slug ("RFC", "Spec", "Feature") is a presentation concern driven by the
collection's `type` (§22.4a), not part of the identity.
**Legacy numbers.** Entries graduated *before* this change keep their existing
`id` (`RFC-NNNN`) in frontmatter as a **frozen, non-identity legacy label**
preserved and shown so external "RFC-0001"-style citations still resolve, but
never used for routing or lookup. New entries are never assigned one.
### 22.4a Collection type
Every collection declares a `type` in its `.collection.yaml` manifest
(§22.2), chosen at creation and **immutable**: one of `document`,
`specification`, or `bdd`. Type does not change the engine — every type uses
the same content repo (§22.3), the same propose→branch→PR→discuss→graduate
lifecycle (§§913), the same threads, flags, and chat. Type selects exactly
three things:
1. the **entry frontmatter schema** the collection validates entries against (§2);
2. the **terminology** the chrome uses for an entry (the §8.1 noun, catalog labels);
3. the set of **type-specific surfaces** layered on top of the shared §7 catalog.
Type-specific behavior is implemented as a per-type module the framework
selects on `collection.type`; the engine itself treats every entry as
markdown + frontmatter regardless of type. `type` is an **open set** in shape
— a future type is a new module plus a new allowed enum value, no schema
rebuild. The type names and their behavior are framework concepts (like role
names), not deployment content: a deployment picks which type each collection
is, but does not define or rename types.
- **`document`** — long-form normative prose (OHM: a model of principles and
definitions). Frontmatter is the §2 baseline. No type-specific surfaces. The
§22.13 generated default collection is a `document` collection, so the N=1
case is unchanged.
- **`specification`** — a versioned technical specification (this framework's
own `SPEC.md` is the archetype). Frontmatter adds spec metadata (`version`,
lifecycle `status` of draft/active/superseded, `supersedes`). Type-specific
surface — **release planning:** group entries/changes into versioned
releases with a changelog + §20-style upgrade-steps per release.
- **`bdd`** — behavior-driven feature specs: each entry states a feature as
Given/When/Then scenarios with acceptance criteria. Frontmatter adds feature
metadata and an optional link to the `specification` entries a feature
verifies. Type-specific surface: a scenario/acceptance view and a coverage
view mapping features to the spec sections they exercise.
### 22.4b Initial state of a new entry
A collection sets the **landing state** a new entry takes when its creating
idea-PR merges (§2.4) — the `initial_state` manifest field, one of the §2.4
entry-states:
- **`super-draft`** (default for `document` and `specification`) — a new entry
lands as a super-draft and must be explicitly graduated (§13) to reach
`active`. This is today's flow.
- **`active`** (default for `bdd`) — a new entry lands `active` on the idea-PR
merge, with the **`unreviewed` flag set** (§22.4c). The §13 graduate gate is
not surfaced — the entry is already active — but because nothing reviewed
it, the flag marks it as not-yet-vetted until an owner clears it.
The default comes from the collection's **type** (§22.4a), but `initial_state`
is an independent knob. It changes only the landing state and whether
graduation is required; the underlying engine is unchanged. The §22.13 default
collection keeps `super-draft`, preserving the N=1 flow.
### 22.4c The `unreviewed` flag
An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
`state`, recording whether a human gate has vetted it. An entry that reaches
`active` by the normal **graduate** path (§13) is never flagged — the graduate
action *is* the review. An entry that skips straight to `active` via
`initial_state: active` lands `unreviewed = true`. A collection **Owner**
(§22.6) clears it with a **mark-reviewed** action (§17), stamping
`reviewed_at`/`reviewed_by` for provenance. The flag is git-truth
(frontmatter, §2 amendment) and survives a cache rebuild. The §7 catalog gains
an **unreviewed filter** — the owner's worklist for the action.
### 22.5 Visibility applies at both project and collection
Visibility is `gated` | `public` | `unlisted`, and is carried at **both** the
project and the collection tier:
- **`gated`** — invisible to non-members: not shown in the directory (§22.10),
returns 404 to non-members, and reading or writing requires a scope role
(§22.6).
- **`public`** — any visitor may read under the §6.1 anonymous-read contract;
appears in the directory; contributing still requires a grant (subject to
the N=1 baseline, §22.6).
- **`unlisted`** — readable by anyone with a direct link, but not shown in the
directory and not enumerated by the deployment/project listing.
**A collection defaults to its project's visibility and may only narrow it**
(`public` < `unlisted` < `gated`; a collection may be as strict or stricter
than its project, never looser). Reading or writing a collection requires
passing **both** gates — the stricter of project and collection wins. This is
validated at create-collection (422 on a looser setting) and clamped at the
registry mirror. Project/collection visibility does not relax the §11
per-branch `read_public` controls *within* a collection.
### 22.6 Roles: one 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 literal — the same two words at every 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. (A *collection*-scope grant cannot create sibling collections — creating one is a project-level action.) |
**Schema.** `users.role` continues to carry the **deployment admission** tier
(`owner` / `admin` / `contributor`, the §6 gate). Scope grants live in a single
polymorphic **`memberships(scope_type ∈ {global, project, collection},
scope_id, user_id, role, granted_by, granted_at)`** table; the role enum is
`{owner, contributor}`. The prior `project_members` three-role set
(`viewer`/`contributor`/`admin`) collapsed: `admin → owner`, `contributor →
contributor`, and `viewer` is deferred (a read grant folded into visibility,
not a membership role this pass). When the richer set returns it **re-splits
out of** Owner / re-adds a tier; the unified roles are not aliases.
> **Keystone reinterpretation (v0.42.0, S3 — reconciles the role mapping).**
> An earlier draft equated "deployment `contributor`" with "global RFC
> Contributor." That contradicted the open-by-default baseline. The binding
> reading: a **plain granted account** (`users.role='contributor'`, no
> membership row) is a granted *account* — it can sign in and read — **not** a
> write-everywhere global role. **"Global RFC Contributor"** is an **explicit
> `memberships(scope_type='global')` grant** (the cleo case, §22.6a). The one
> carve-out preserving N=1: the pre-multi-project **implicit-public write
> baseline is grandfathered onto the migration-seeded `default` collection
> only** — a granted `contributor` keeps its historical write capability there
> with no membership row. Every **explicitly created** collection (and any
> second project) requires an explicit scope grant to write. Deployment
> `owner`/`admin` remain superusers everywhere.
Membership is still gated by the deployment-level
`users.permission_state='granted'` (§6): a pending account has no write
capability at any scope regardless of its `memberships` rows.
### 22.6a Role & invitation scenarios
The behavioral spec for role usage, invitation, and empty-state experiences is
the BDD scenario set in
`docs/design/2026-06-05-three-tier-projects-collections.md` Part C (C.1 role
usage / inheritance / most-permissive union; C.2 invitation — who may invite
whom, at which scope; C.3 empty states at each tier). They are written so they
can also seed a `bdd`-type collection (the framework dogfooding its own model).
Each scenario carries the slice tag (`@S1``@S6`) that makes it pass.
### 22.7 How the four tiers compose
Effective authority on an entry is the **most permissive** union of four
layers, inheriting **downward**, **additive**, with **no negative override**:
```
effective authority on an entry =
global role (memberships scope_type='global'; + users.role owner/admin)
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)
```
- A grant at **global** covers every project and collection in the deployment.
- A grant at **project** covers every collection in that project — including
collections added later, with no new grant.
- A grant at **collection** covers just that collection.
- You **cannot** grant at a parent scope and revoke at a child; resolution
never subtracts a parent grant.
**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*. `arbiter` is narrower than Owner
(one entry, not a subtree) and stays distinct. `users.role` now means
deployment level only; no schema change demotes an existing owner/admin —
their powers read as "superuser in every project and collection."
### 22.8 Discovery and joining
Because a gated project or collection is invisible to non-members, joining is
by one of:
- **Invite** — an Owner (at the target scope or any scope above it) grants a
user a role directly, writing a `memberships` row and fanning a §15
notification. The grant may name any scope at or beneath the inviter's reach;
the **broader-scope-supersedes** rule prunes membership rows the new grant
subsumes (a project grant removes subsumed collection rows of same-or-lower
rank; a global grant removes subsumed project + collection rows; a *stronger*
child grant survives).
- **Request to join** — a user who knows a scope exists requests membership
naming a desired role; the request is recorded and surfaced to the scope's
Owners across the subtree (the cross-collection inbox), who accept or decline.
Accepting writes the `memberships` row.
A `public` project/collection needs neither for read; the existing §6 / §12
contribute-grant paths cover write.
### 22.9 Branding is resolved at runtime
`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
cannot serve N projects. Deployment, project, and collection identity are
served at runtime — `GET /api/deployment` (deployment `name`, `tagline`, the
visible projects), `GET /api/projects/:id` (the project's settings + visible
collections), `GET /api/projects/:id/collections/:cid` (the collection's
settings incl. `type`). The frontend reads these instead of
`import.meta.env.VITE_APP_NAME`. Three chrome layers result: **deployment
chrome** (directory, switcher, shared inbox), **project chrome** (the
collection directory, project settings), and **collection chrome** (the §7
catalog, the §8 entry view, the §14 philosophy).
### 22.10 Routing and the landing surfaces
The canonical route gains a collection segment:
```
/p/<project>/c/<collection>/e/<slug>
```
The `c/` segment keeps collection ids from colliding with reserved
project-level segments. Reserved **collection-level** siblings (`proposals`,
`philosophy`) sit under `/p/<project>/c/<collection>/…`. The displayed entry
noun is the collection type's label (§22.4a), not part of the path.
- `/` is the **deployment landing**: a directory of the projects the visitor
can see (§22.5). Redirects to the sole visible project when there is exactly
one (the N=1 case).
- `/p/<project>/` is the **project landing**: a directory of the collections
the visitor can see. Redirects to its sole visible collection when there is
exactly one.
**Backcompat.** The shipped `/p/<project>/e/<slug>` URLs (v0.35.0)
**308-redirect** to `/p/<project>/c/<default>/e/<slug>`, and the
pre-multi-project `/rfc/<slug>` / `/proposals/<n>` redirect to their
`/p/<default-project>/c/<default-collection>/…` equivalents. Both are handled
in the migration (§22.13).
### 22.11 Notifications span the deployment, one inbox
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
caller's collections. Entry-scoped notification rows carry the entry's
`collection_id` (and a denormalized `project_id`) so the inbox filters by
collection or project and a user can mute an entire collection. Quiet hours,
digest cadence, and email preferences stay per-account at the deployment level
(§5, §15). The **cross-collection inbox** (§22.8) surfaces join requests to
the Owners of the scope they target, aggregated across the subtree.
### 22.12 Per-collection model universe
A collection's `enabled_models` (its `.collection.yaml` manifest, §22.2)
narrows its **project's** `enabled_models` (registry, §22.2), which in turn
overrides the deployment `ENABLED_MODELS` (§18). Resolution order is **funder
universe ∩ §6.6 per-entry list ∩ collection universe ∩ project universe**,
with the collection universe substituting for the deployment universe at the
outermost step. A collection's universe may only narrow, never widen, its
project's; the project's may only narrow the deployment's.
### 22.13 Migration — the default project and default collection (N=1)
A deployment on the shipped two-tier schema (v0.39.0) is migrated so it keeps
running unchanged:
1. The existing `projects` row **stays as the project** (it already owns
`content_repo` and its config-derived `id` from the §22.13 re-stamp).
2. A **default collection** (`id='default'`, `subfolder` = repo root) is
created per project, inheriting that project's `type` / `initial_state` /
visibility; those per-corpus fields are then dropped from `projects`.
3. Every entry-scoped row is re-keyed `(project_id, slug)`
`(collection_id, slug)` via the migration-028 rebuild pattern.
4. `project_members` rows migrate to `memberships(scope_type='collection')` on
the default collection, role-collapsed (§22.6).
5. **308 redirects:** the shipped `/p/<project>/e/<slug>`
`/p/<project>/c/<default>/e/<slug>`, and the pre-multi-project `/rfc/<slug>`
/ `/proposals/<n>` → their `/p/<project>/c/<default>/…` equivalents.
Until a second collection is added, the deployment is functionally identical
to before, with one extra path segment. This is the §20.4 upgrade-steps
content for the release.
### 22.14 Amendments to §§121 (applied in place)
The single-corpus sections defer to §22; the load-bearing reinterpretations:
- **§1 Repository topology.** Each *project* names one content repo; the
deployment's registry (§22.2) lists them; collections are subfolders within
a project's repo (§22.3). The bot and app-owned-authorization paragraphs are
unchanged and now read org-wide.
- **§2 Schema / §2.3 IDs.** Slugs are unique **per collection**; the entry
frontmatter schema is **type-dependent** (§22.4a). The `RFC-NNNN` `max+1`
allocation is **removed** — the slug is the identity (§22.4). New
`active`-entry fields: `unreviewed` (bool) and the `reviewed_at`/
`reviewed_by` provenance pair (§22.4c).
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition
targets the collection's `initial_state` (§22.4b); a new `active
─[mark-reviewed, Owner]→ active` self-transition clears the `unreviewed`
flag (§22.4c).
- **§5 Data model.** `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.
`cached_rfcs` PK → `(collection_id, slug)` and mirrors the `unreviewed`
frontmatter flag. New tables: `projects`, `collections` (carrying the
immutable `type`, §22.4a), and `memberships` (§22.6, replacing
`project_members`). `users.role` is annotated deployment-scope (§22.7).
- **§6 Permission model.** Deployment roles are the global tier of the §22.7
four-layer union; a plain `contributor` has no implicit write at any
explicitly-created scope until a `memberships` grant gives it one (the N=1
default-collection baseline is the sole carve-out, §22.6 keystone note).
`project_admin`'s delegation idea is now **Owner** at project/collection
scope (§22.6), sitting above per-RFC authority (§6.3).
- **§7 / §8.1 / §13.3.** The catalog is per-collection under
`/p/<project>/c/<collection>/`; the project collection-directory and the
deployment directory (§22.10) sit above it; the §7 catalog gains the
unreviewed filter (§22.4c). The §8.1 breadcrumb gains leading project +
collection segments. §13.3 graduation operates on the collection's content
subfolder, allocates no number, and is a no-op (replaced by mark-reviewed)
for collections whose `initial_state` is `active`.
- **§14.1 / §17 / §18 / §20.** The landing splits into deployment directory,
project collection-directory, and per-collection philosophy/deck. §17 routes
gain the `/p/<project>/c/<collection>/` scoping plus `GET /api/deployment`,
`GET /api/projects/:id`, `GET /api/projects/:id/collections[/:cid]`, the
`memberships` management + request-to-join endpoints, and the mark-reviewed
endpoint. `ENABLED_MODELS` is the deployment fallback under §22.12.
`VITE_APP_NAME` is deprecated and `REGISTRY_REPO` is a required env var
(§20.3); `META_REPO` is legacy, consulted only by the §22.13 migration.
+93
View File
@@ -90,6 +90,99 @@ the framework at that pin against your Gitea; the framework knows
nothing about your specific deployment beyond what your `.env` nothing about your specific deployment beyond what your `.env`
files told it. files told it.
## The registry: projects and collections (§22)
From **v0.45.0** a deployment is **three tiers** — deployment →
**project** → **RFC collection** — instead of one corpus. Most of
the single-corpus setup above still applies (a deployment that wants
one corpus runs the **N=1 case** unchanged), but two things move into
**git-truth the framework mirrors**, exactly the way RFC bodies do.
The binding model is [`SPEC.md` §22](../SPEC.md); this is the operator
view of the file formats.
### The registry repo (`REGISTRY_REPO`) declares projects
`VITE_APP_NAME` and `META_REPO` are superseded. The framework now reads
a **registry repo** — a dedicated repo under your Gitea org, named
whatever you like, whose location you pass in `backend/.env` as
`REGISTRY_REPO` (the framework fails loudly at startup if it is unset).
Its root holds a `projects.yaml`:
```yaml
# projects.yaml (registry repo root)
deployment:
name: Wiggleverse # deployment display name (was VITE_APP_NAME)
tagline: A substrate for collaborative standardization
projects:
- id: ohm # url-stable slug, unique in the deployment → /p/ohm/
name: Open Human Model
content_repo: ohm-content # ONE repo under your org; collections live inside it
visibility: public # gated | public | unlisted (§22.5)
theme: { accent: "#5b5bd6" } # optional per-project token overrides
enabled_models: [claude, gemini] # optional; falls back to ENABLED_MODELS
```
Each project owns **exactly one content repo**. Adding, reconfiguring,
or archiving a project is a PR against `projects.yaml`; the framework's
webhook + reconciler mirror it into the `projects` cache table. Project
**membership** is app state (it churns at user speed), not registry
data. The deployment's display name and tagline come from here, served
at runtime via `GET /api/deployment` — no rebuild needed to rename.
### The content repo declares collections via `.collection.yaml`
A project's collections are **typed subfolders** of its content repo,
each carrying a `.collection.yaml` manifest the registry mirror reads:
```
ohm-content/
model/
.collection.yaml # type: document
rfcs/intro.md
specs/
.collection.yaml # type: specification
rfcs/runtime.md
features/
.collection.yaml # type: bdd
rfcs/login.md
```
```yaml
# ohm-content/model/.collection.yaml
type: document # document | specification | bdd — IMMUTABLE once set
visibility: gated # defaults to the project's; may only NARROW it
initial_state: super-draft # super-draft | active — defaults from type
name: The Model
# enabled_models: [claude] # optional; may only narrow the project's universe
```
`type` is fixed at creation (the framework refuses to change it on a
later mirror). `visibility` may be as strict as or stricter than the
project's, never looser (`public` < `unlisted` < `gated`); reading or
writing a collection requires passing **both** the project and the
collection gate. `enabled_models`, if present, narrows the project's
model universe for that collection.
### Default project + default collection (the N=1 upgrade)
A deployment upgrading from a pre-§22 version is migrated automatically:
its single corpus becomes one **default project** carrying one **default
collection** (`id` `default`, subfolder = repo root), inheriting the old
`type` / `initial_state` / visibility. Old URLs 308-redirect into the
`/p/<project>/c/default/…` form, so existing links survive. Until you
add a second project or collection the deployment is functionally
identical to before, with one extra path segment. The ordered upgrade
actions are the [`CHANGELOG.md`](../CHANGELOG.md) entry's upgrade-steps
block (§20.4).
### Creating projects and collections in-app
Both tiers can also be created from the UI by an Owner — the action
wraps a bot commit (a new content repo + `projects.yaml` entry for a
project; a new subfolder + `.collection.yaml` for a collection), so
everything still flows from git. Nothing becomes app state the mirror
cannot rebuild.
## The two-repo working pattern ## The two-repo working pattern
Once a deployment is live, day-to-day changes split across the Once a deployment is live, day-to-day changes split across the