From 26f3680197e43923cb24e45281161e6917298905 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Sat, 6 Jun 2026 01:21:39 -0700 Subject: [PATCH] =?UTF-8?q?=C2=A722=20S6:=20merge=20three-tier=20model=20i?= =?UTF-8?q?nto=20SPEC.md=20+=20registry=20format=20in=20DEPLOYMENTS?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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//c// 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) --- SPEC.md | 525 ++++++++++++++++++++++++++++++++++++++++++++ docs/DEPLOYMENTS.md | 93 ++++++++ 2 files changed, 618 insertions(+) diff --git a/SPEC.md b/SPEC.md index 0242b02..4679af0 100644 --- a/SPEC.md +++ b/SPEC.md @@ -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 > 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 service account** in Gitea. Real human users do not have meaningful Gitea 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** 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 ```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 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 of `owner` / `admin` / `contributor`), `muted` (bool — the §6.2 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. +> **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 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 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 §§1–21 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 §§1–21 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 S1–S6 (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 `-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 (§§9–13), 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//c//e/ +``` + +The `c/` segment keeps collection ids from colliding with reserved +project-level segments. Reserved **collection-level** siblings (`proposals`, +`philosophy`) sit under `/p//c//…`. 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//` 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//e/` URLs (v0.35.0) +**308-redirect** to `/p//c//e/`, and the +pre-multi-project `/rfc/` / `/proposals/` redirect to their +`/p//c//…` 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//e/` → + `/p//c//e/`, and the pre-multi-project `/rfc/` + / `/proposals/` → their `/p//c//…` 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 §§1–21 (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//c//`; 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//c//` 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. + diff --git a/docs/DEPLOYMENTS.md b/docs/DEPLOYMENTS.md index 18e3e96..c7da83d 100644 --- a/docs/DEPLOYMENTS.md +++ b/docs/DEPLOYMENTS.md @@ -90,6 +90,99 @@ the framework at that pin against your Gitea; the framework knows nothing about your specific deployment beyond what your `.env` 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//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 Once a deployment is live, day-to-day changes split across the