§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:
@@ -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 `<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 (§§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/<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 §§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/<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.
|
||||
|
||||
|
||||
@@ -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/<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
|
||||
|
||||
Once a deployment is live, day-to-day changes split across the
|
||||
|
||||
Reference in New Issue
Block a user