Compare commits

...

20 Commits

Author SHA1 Message Date
ben.stull 58be88d085 Hide nested specs collection (moved to standalone rfc-app project)
regenerate-readme-index / noop (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:39:10 -07:00
ben.stull 5d0cadda3b Sync scoped-admin-ia entry to v0.1.2 (§4.5 collection discovery)
regenerate-readme-index / noop (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:19:36 -07:00
ben.stull cc8d371833 Make 'specs' collection public so it lists in the OHM project directory
regenerate-readme-index / noop (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:14:31 -07:00
ben.stull 85edd17ac4 Add 'specs' collection: rfc-app solution designs for in-app review
regenerate-readme-index / noop (push) Has been cancelled
Seeds the framework's docs/design/*.md as a document collection on the ohm
project (subfolder specs/, unlisted) so the solution designs can be reviewed
and collaborated on in the RFC app until graduated. Dogfood from session
OHM-0090.0.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:09:41 -07:00
ben.stull f65a2234f7 Graduate wise-speech-commerce → RFC-0006
regenerate-readme-index / noop (push) Has been cancelled
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 15:23:27 +00:00
ben.stull 056acd360f Graduate wise-speech-commerce → RFC-0006
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 15:23:25 +00:00
ben.stull 8e56ed72d8 Graduate non-extraction → RFC-0005
regenerate-readme-index / noop (push) Has been cancelled
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 12:44:55 +00:00
ben.stull ef3a1ad331 Graduate non-extraction → RFC-0005
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 12:44:53 +00:00
ben.stull 8c7c6ff9c1 Graduate trust-safety → RFC-0004
regenerate-readme-index / noop (push) Has been cancelled
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 12:43:40 +00:00
ben.stull 6883f8b6c8 Graduate trust-safety → RFC-0004
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 12:43:39 +00:00
ben.stull e24e82c6ad Merge proposal: addressability
regenerate-readme-index / noop (push) Has been cancelled
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:38:17 -07:00
ben.stull 214f69e08e Merge proposal: reference-pricing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:38:17 -07:00
ben.stull 656d5627f2 Propose: Durable Addressability
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:38:17 -07:00
ben.stull d21470ccc4 Propose: Reference Pricing
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 22:38:17 -07:00
ben.stull 04afcd9bd6 Graduate non-human-entities → RFC-0003
regenerate-readme-index / noop (push) Has been cancelled
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 02:07:11 +00:00
ben.stull a0a136fe3d Graduate non-human-entities → RFC-0003
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 02:07:10 +00:00
ben.stull 96a11ec96c Graduate flourishing → RFC-0002
regenerate-readme-index / noop (push) Has been cancelled
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 02:05:41 +00:00
ben.stull 039555a85e Graduate flourishing → RFC-0002
On-behalf-of: Ben Stull <ben.stull>
2026-06-04 02:05:39 +00:00
ben.stull 619adde407 Withdraw 7 dash-less duplicate RFC entries
regenerate-readme-index / noop (push) Has been cancelled
Set state: withdrawn on the hyphen-stripped slug duplicates created during
the ecomm corpus seeding (agencyantimanipulation, privacydataminimization,
epistemicauthority, wisespeechcommerce, dignitycommerce, dutyofcare,
nonextraction). Their correctly-slugged twins (agency-anti-manipulation,
etc.) remain the live entries. Withdrawn keeps them as historical record
per SPEC §3 while dropping them from the super-draft/active lists.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 16:32:19 -07:00
ben.stull 5abee1e203 Merge proposal: non-extraction
regenerate-readme-index / noop (push) Has been cancelled
On-behalf-of: Ben Stull <ben.stull>
2026-06-01 18:59:00 +00:00
22 changed files with 3318 additions and 32 deletions
+18
View File
@@ -0,0 +1,18 @@
---
slug: addressability
title: Durable Addressability
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-03'
graduated_at: null
graduated_by: null
owners:
- ben.stull
arbiters: []
tags:
- commerce
---
An address is the public path a Human reaches a thing at — a product's URL — and once it has been shared, linked, or indexed it becomes a durable promise rather than a disposable string. This RFC defines Durable Addressability so that changing the address of a published thing preserves the old path by redirect, rather than silently breaking every inbound link, bookmark, and citation that trusted it. It applies No Retroactive Harm to the namespace and supports Exit & Portability, treating reachability as something owed to the people on the other end of a link, not a convenience the platform may revoke.
+1 -1
View File
@@ -1,7 +1,7 @@
---
slug: agencyantimanipulation
title: Agency & Anti-Manipulation
state: super-draft
state: withdrawn
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
+1 -1
View File
@@ -1,7 +1,7 @@
---
slug: dignitycommerce
title: Dignity in Commerce
state: super-draft
state: withdrawn
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
+1 -1
View File
@@ -1,7 +1,7 @@
---
slug: dutyofcare
title: Duty of Care & Vulnerability
state: super-draft
state: withdrawn
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
+1 -1
View File
@@ -1,7 +1,7 @@
---
slug: epistemicauthority
title: Epistemic Authority
state: super-draft
state: withdrawn
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
+7 -5
View File
@@ -1,19 +1,21 @@
---
slug: flourishing
title: Human Flourishing (The Optimization Target)
state: super-draft
id: null
state: active
id: RFC-0002
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-01'
graduated_at: null
graduated_by: null
graduated_at: '2026-06-04'
graduated_by: ben.stull
owners:
- ben.stull
arbiters: []
arbiters:
- ben.stull
tags:
- ethics
---
Every platform optimizes for something; when that target is left implicit it silently becomes engagement, GMV, or time-on-site, and the mechanics follow it into harm. This RFC makes the optimization target explicit and contestable: what does this platform actually steer toward for the Humans in it? It is the affirmative answer the other ethical concepts are in service of, and every ranking, default, and notification cadence should be traceable back to it.
+7 -5
View File
@@ -1,19 +1,21 @@
---
slug: non-extraction
title: Non-Extraction
state: super-draft
id: null
state: active
id: RFC-0005
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-01'
graduated_at: null
graduated_by: null
graduated_at: '2026-06-04'
graduated_by: ben.stull
owners:
- ben.stull
arbiters: []
arbiters:
- ben.stull
tags:
- ethics
---
A platform may need to capture value to survive, but the moment it sustains itself by extracting from the very Humans it serves, it has betrayed them. This RFC defines Non-Extraction as the structural commitment that separates this platform from the Shopify-style model of referral fees, payment markups, and sponsored placement. It is the load-bearing claim behind 'better for owners and customers,' and it makes that claim checkable rather than rhetorical.
+7 -5
View File
@@ -1,19 +1,21 @@
---
slug: non-human-entities
title: Non-Human Entities in OHM
state: super-draft
id: null
state: active
id: RFC-0003
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-01'
graduated_at: null
graduated_by: null
graduated_at: '2026-06-04'
graduated_by: ben.stull
owners:
- ben.stull
arbiters: []
arbiters:
- ben.stull
tags:
- foundations
---
OHM is the Open *Human* Model, yet any real platform must also model things that are not humans — commerce nouns, instruments, systems. This RFC establishes how OHM may define a non-human entity at all: always in relation to the Human it serves, never as a free-standing abstraction. It is the conscious hinge on which the model broadens to hold commerce vocabulary without losing its human center, and it gives every later commerce concept a rule to be defined against.
+1 -1
View File
@@ -1,7 +1,7 @@
---
slug: nonextraction
title: Non-Extraction
state: super-draft
state: withdrawn
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
+1 -1
View File
@@ -1,7 +1,7 @@
---
slug: privacydataminimization
title: Privacy & Data Minimization
state: super-draft
state: withdrawn
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
+18
View File
@@ -0,0 +1,18 @@
---
slug: reference-pricing
title: Reference Pricing
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-03'
graduated_at: null
graduated_by: null
owners:
- ben.stull
arbiters: []
tags:
- commerce
---
A reference price is any comparison a listing shows against its current selling price — a struck-through "was" price, a "compare at", an implied discount — and it is a factual claim about the past, not a persuasion device. This RFC defines Reference Pricing so that a displayed former or comparison price must reflect a genuine prior selling price for that good, never a fabricated anchor invented to manufacture the appearance of a discount. It refines Price and applies Wise Speech in Commerce and Agency & Anti-Manipulation to one specific deception — inventing the "before" in order to sell the "after" — and so protects a Human's ability to judge a deal on true information.
+7 -5
View File
@@ -1,19 +1,21 @@
---
slug: trust-safety
title: Trust & Safety / Harm Prevention
state: super-draft
id: null
state: active
id: RFC-0004
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-01'
graduated_at: null
graduated_by: null
graduated_at: '2026-06-04'
graduated_by: ben.stull
owners:
- ben.stull
arbiters: []
arbiters:
- ben.stull
tags:
- commerce
---
A marketplace that admits any seller and any good is not neutral; it is negligent toward the Humans it exposes to fraud and danger. This RFC defines who may sell and what may be sold — prohibited, dangerous, and counterfeit goods, and scam prevention — as a duty of harm prevention distinct from after-the-fact recourse. It protects buyers and honest merchants alike.
+7 -5
View File
@@ -1,19 +1,21 @@
---
slug: wise-speech-commerce
title: Wise Speech in Commerce
state: super-draft
id: null
state: active
id: RFC-0006
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-01'
graduated_at: null
graduated_by: null
graduated_at: '2026-06-04'
graduated_by: ben.stull
owners:
- ben.stull
arbiters: []
arbiters:
- ben.stull
tags:
- ethics
---
Listings, prices, and reviews are speech the platform either keeps honest or lets deceive. This RFC applies OHM's foundational Wise Speech value to commerce: descriptions that are truthful, prices that do not mislead, and a platform that does not amplify deceptive speech for its own gain. It protects a Human's ability to choose on the basis of true information.
+1 -1
View File
@@ -1,7 +1,7 @@
---
slug: wisespeechcommerce
title: Wise Speech in Commerce
state: super-draft
state: withdrawn
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
View File
@@ -0,0 +1,689 @@
---
slug: 2026-06-05-three-tier-projects-collections
title: "Three-Tier: Projects → Collections (§22)"
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-05'
graduated_at: null
graduated_by: null
owners:
- ben.stull@wiggleverse.org
arbiters: []
tags:
- solution-design
---
# Draft spec — §22 refactor: three tiers (deployment → project → RFC collection)
> Status: **draft for review.** Binding voice, but not yet merged into
> `SPEC.md`. This doc **revises the §22 model** in
> [`multi-project-spec.md`](./multi-project-spec.md) from two tiers
> (deployment → project, where a "project" *is* a corpus) to **three tiers**
> (deployment → project → RFC collection, where the *collection* is the
> corpus). It supersedes the conflicting parts of that draft; the parts it does
> not touch (the registry-is-git-truth stance, the cache mirror, visibility
> semantics, the `type`/`initial_state`/`unreviewed` machinery) carry over
> unchanged, re-homed onto the collection. Rationale and the decisions behind
> this live in [`multi-project.md`](./multi-project.md) and session 0072.
>
> ⚠️ **CORRECTION (session 0072, after code re-check).** Parts of §0/§A.3/§E
> were drafted on a stale-memory premise that "Plan B (migration 028) and
> M3-frontend have not shipped." **That is false.** As of v0.39.0 the entire
> **two-tier** model is shipped to `main`: migration 028 already rebuilt the
> slug PK to `(project_id, slug)`; v0.35.0 shipped `/p/<project>/` routing and
> the live `/p/<project>/e/<slug>` URLs; v0.37.0/0.38.0 shipped per-project
> read + propose. Inserting the third tier is therefore an **evolution of a
> shipped system**, not a revision of unshipped designs. The migration strategy
> (Part E) was **re-decided on these corrected facts** (session 0072): a new
> **migration 029** adds a *collection* grain *beneath* today's project, plus a
> breaking `/p/<project>/e/<slug>` → `/p/<project>/c/<collection>/e/<slug>` URL
> change with 308s. The structural model (Parts AD) is unaffected. Target
> release: a further pre-1.0 minor with breaking changes + upgrade steps (§20.2).
---
## 0. Why this revision
The original §22 (`multi-project-spec.md`) gave a deployment **N projects**,
where each project *was* a single typed corpus: one content repo, one `type`,
one slug namespace, one member roster. That conflates two responsibilities —
**organizational grouping** and **a typed body of entries** — into one noun.
This revision splits them. A **project** becomes a pure grouping tier (settings
+ one content repo) that holds **any number of RFC collections**; an **RFC
collection** is the typed corpus the original §22 called a "project." Everything
the original §22 said about a corpus (type, slug namespace, catalog, philosophy,
landing state, review flag, membership) moves down one level to the collection;
the deployment level is unchanged.
⚠️ The two-tier model is **already shipped** (v0.39.0): migration 028 rebuilt
the slug PK to `(project_id, slug)`, and `/p/<project>/e/<slug>` URLs are live
(v0.35.0). So inserting the third tier evolves a shipped system — see Part E
for the decided strategy (a new migration 029 adding a collection grain beneath
today's project, + a breaking URL change with 308s).
---
# Part A — The three-tier model
## A.1 The tiers
```
deployment (= "global" in the UI) one Gitea org, one bot, one account
│ system, one inbox, one running process;
│ the surface a visitor first lands on.
└─ project ◀ NEW a named grouping + project settings;
│ owns exactly ONE content repo. No type.
└─ RFC collection a typed corpus: type, slug namespace,
│ catalog, philosophy, initial_state,
│ unreviewed flag, members. (= what the
│ original §22 called a "project".)
└─ entry an RFC / spec / feature, identified by
its slug within the collection.
```
- **Deployment / "global."** Unchanged top tier. Owns accounts, the §6
admission gate, the §15 inbox, the §1 bot, and the deployment landing
directory. Its management surface is **projects + global settings**.
- **Project** *(new)*. Belongs to exactly one deployment; never moves. Owns one
content repo (§A.2) and carries project settings (name, tagline, theme,
visibility, model universe). Has **no `type`** of its own. Its management
surface is **RFC collections + project settings**.
- **RFC collection.** A typed subfolder of its project's content repo (§A.2).
Carries everything the original §22 pinned on a "project": the immutable
`type` (§22.4a `document` | `specification` | `bdd` | …), the per-collection
slug namespace (§A.3), `initial_state` (§22.4b), the `unreviewed` flag
(§22.4c), catalog, philosophy. This is "closest to what OHM originally
managed as a single corpus."
- **Entry.** Unchanged (§2). Identified by its slug **within its collection**.
A collection belongs to exactly one project; a project to exactly one
deployment. Isolation (§22.1) now holds at the **collection** grain: an RFC,
branch, thread, star, or watch belongs to exactly one collection.
## A.2 Storage and git-truth
Two git sources, both read by the bot, both mirrored into cache tables the §4
way:
1. **The registry repo** (`projects.yaml`, located by `REGISTRY_REPO`, §22.2)
declares **projects**`id`, `name`, `content_repo`, settings, `visibility`,
`theme`, `enabled_models`. `content_repo` moves **up** from the collection
(original §22) to the project: a project owns exactly one content repo.
2. **Each project's content repo** declares its **collections** as typed
subfolders, each carrying a **`.collection.yaml` manifest** (the collection's
`type`, `visibility`, `initial_state`). The registry mirror walks the content
repo and reads these manifests, so collection configuration is git-truth and
survives a cache rebuild — exactly as entry frontmatter does.
```yaml
# projects.yaml (registry repo root)
deployment:
name: Wiggleverse
tagline: ...
projects:
- id: ohm
name: Open Human Model
content_repo: ohm-content # ONE repo; collections live inside it
visibility: public # gated | public | unlisted (§22.5)
theme: { accent: "#5b5bd6" }
enabled_models: [claude, gemini]
```
```yaml
# ohm-content/features/.collection.yaml (one per collection subfolder)
type: bdd # document | specification | bdd — immutable
visibility: gated # defaults to the project's, may narrow
initial_state: active # defaults from type (§22.4b)
name: Feature scenarios
```
```
ohm-content/
model/
.collection.yaml # type: document
intro.md
specs/
.collection.yaml # type: specification
runtime.md
features/
.collection.yaml # type: bdd
login.md
```
**Creation is in-app, wrapping a bot commit, at both tiers:**
- **+ New project** (a global Owner action): the bot **creates a Gitea content
repo** under the deployment org, **commits a project entry** to
`projects.yaml`, and the mirror picks it up.
- **+ New collection** (a project Owner / RFC Contributor-with-create action):
the bot **commits a new subfolder + `.collection.yaml`** to the project's
content repo; the mirror picks it up.
The in-app button is a thin convenience over a git write; nothing becomes app
state that git cannot rebuild. `projects` and `collections` cache rows are never
written from user actions directly — they flow from the mirror only (§22.2).
**Membership** (§B-roles) remains app state, as `rfc_collaborators` always has
been — it churns at user speed and is not document state.
## A.3 Identity and routing
The slug is unique **within a collection**; the fully-qualified identity is
`(project, collection, slug)`. `model/intro` and `specs/intro` coexist. No type
prefix, no numbers (the §22.4 retirement of `RFC-NNNN` allocation stands;
legacy `id` frontmatter remains a frozen, non-identity display label).
Canonical route:
```
/p/<project>/c/<collection>/e/<slug>
```
The `c/` segment keeps collection ids from colliding with reserved
project-level segments (project settings, the collection directory). Reserved
**collection-level** siblings (`proposals`, `philosophy`) sit under
`/p/<project>/c/<collection>/…`. The displayed entry noun ("RFC", "Spec",
"Feature") is the collection type's label (§22.4a), not part of the path.
The root `/` is the deployment landing: a **directory of projects** the visitor
can see (§22.5). `/p/<project>/` is the project landing: a **directory of
collections** in that project the visitor can see. Conveniences:
- `/p/<project>/` redirects to its sole collection when the project has exactly
one visible collection.
- `/` redirects to the sole visible project when there is exactly one (the N=1
case, §A.6).
⚠️ **Backcompat is heavier than first drafted.** `/p/<project>/e/<slug>` URLs
**are live** (v0.35.0), so adding the `/c/<collection>/` segment is a breaking
URL change: the shipped `/p/<project>/e/<slug>` must **308-redirect** to
`/p/<project>/c/<default-collection>/e/<slug>`, alongside the pre-multi-project
`/rfc/<slug>``/p/<default-project>/c/<default-collection>/…` redirect. Both
are handled in the migration (§A.6 / Part E).
---
# Part B — Roles and authorization
## B.1 One role vocabulary, attached at a scope
There is **one role enum — `{owner, contributor}`** — displayed as **Owner**
and **RFC Contributor**. A grant *attaches that role at a scope*: **global**,
**project**, or **collection**. "Owner at all levels, RFC Contributor at all
levels" is therefore literal — the same two words at every tier, not a fresh
pair invented per tier.
| Role | Capabilities within its scope's subtree |
|---|---|
| **Owner** | Superuser: manage settings and membership; create child projects/collections; act on any entry (merge on behalf, graduate, mark-reviewed, withdraw/reopen, set branch visibility). |
| **RFC Contributor** | Propose entries, create branches, open PRs, claim unclaimed super-drafts, participate in discussion. At **project** (or global) scope this additionally includes **creating collections** in that project — the "anyone at the project level with permission to create a collection" affordance. (A *collection*-scope grant cannot create sibling collections; creating one is a project-level action.) |
This **reconciles** the role names the prior drafts accumulated — they were
different words for the same idea:
| Prior spec term | Tier it lived at | Unified role |
|---|---|---|
| deployment `owner` / `admin` (§6.1) | global | **Owner** (global) |
| deployment `contributor` (§6.1) | global | **RFC Contributor** (global) |
| `project_admin` (M2 §22.6) | the corpus → now the **collection** | **Owner** (collection) |
| `project_contributor` (M2 §22.6) | the corpus → now the **collection** | **RFC Contributor** (collection) |
| `project_viewer` (M2 §22.6) | the corpus | *deferred* (read-only grant; not one of this pass's two) |
> **Scope-narrowing, not renaming.** Collapsing `owner`/`admin` into one
> **Owner** and dropping `viewer` for this pass are deliberate deferrals (the
> launch ask: "we don't need to get all permissions right yet"). When they
> return they **re-split out of** Owner / add a tier; they are not aliases of
> the unified roles. The richer set is future work.
## B.2 Inheritance and resolution
Grants inherit **downward**, are **additive**, and admit **no negative
override**:
- A grant at **global** covers every project and collection in the deployment.
- A grant at **project** covers every collection in that project.
- A grant at **collection** covers just that collection.
- You **cannot** grant a role at a parent scope and revoke it at a child (the
launch ask: "too complex"). Resolution never subtracts a parent grant.
Effective authority on an entry generalizes the §22.7 most-permissive union
from three layers to four (global → project → collection → per-entry):
```
effective authority on an entry =
global role (users.role)
project role (membership at the entry's project)
collection role (membership at the entry's collection)
per-entry authority (owners / arbiters / rfc_collaborators — §6.3, §12)
then minus §6.2 write-mute and §22.5 visibility (subtractive, as today)
```
**Per-entry authority is a distinct, finer layer — not a synonym.** `owners` /
`arbiters` / `rfc_collaborators` apply to *one specific entry* (§6.3, §12); the
three named scopes apply to a *subtree*. Per-entry authority is unchanged and
sits beneath collection in the union. `arbiter` is narrower than Owner (one
entry, not a subtree) and stays distinct.
## B.3 Schema impact
- `users.role` continues to carry the **global** role (deployment owner /
contributor).
- M2's `project_members(project_id, role)` rows were attached at what we now
call the **collection**. They generalize into a single polymorphic
**`memberships(scope_type ∈ {project, collection}, scope_id, user_id, role,
granted_by, granted_at)`** table; the M2 rows migrate to
`scope_type='collection'`. The **project** tier gets the same two roles,
freshly grantable.
- The M2 three-role enum (`viewer`/`contributor`/`admin`) collapses to
`{owner, contributor}`: `project_admin → owner`, `project_contributor →
contributor`, `project_viewer →` a read grant (no write) folded into
visibility, not a membership role this pass.
---
# Part C — Behavioral scenarios (BDD)
> These Gherkin scenarios are the behavioral spec for **role usage**,
> **invitation**, and **empty-state** experiences. They attach to the rewritten
> §22 as **§22.6a (role & invitation scenarios)**. They are written so they can
> *also* seed a `bdd`-type collection later (the framework dogfooding its own
> model). "Owner"/"RFC Contributor" are the unified roles (§B.1); a *scope* in
> the `Given` is global / project / collection.
>
> **Each scenario carries a `@S<n>` tag** naming the **slice** (Part E) that
> makes it pass — the "which scenarios are done after this slice" marker. After
> shipping slice S<n>, its acceptance gate is "every `@S<n>` scenario passes"
> (e.g. `--tags @S3`). The Part E table is the inverse index (slice →
> scenarios).
## C.1 Role usage — inheritance and the most-permissive union
```gherkin
Feature: Scope roles grant authority over a subtree
As a member of the deployment
I want a role granted at one tier to apply to everything beneath it
So that I can be invited once and work across the right set of collections
Background:
Given a deployment with a project "ohm"
And "ohm" owns collections "model" (document) and "features" (bdd)
@S3
Scenario: Collection RFC Contributor may propose only in that collection
Given "ada" is RFC Contributor at collection "ohm/model"
When "ada" opens the propose form in "ohm/model"
Then she may submit a new entry
When "ada" opens "ohm/features"
Then she sees it read-only and the propose action is not offered
@S3
Scenario: Project RFC Contributor may propose in every collection of the project
Given "ben" is RFC Contributor at project "ohm"
Then "ben" may propose in "ohm/model"
And "ben" may propose in "ohm/features"
And a collection added to "ohm" later is writable by "ben" with no new grant
@S3
Scenario: Global RFC Contributor may propose in every collection of every project
Given a second project "acme" with collection "acme/specs"
And "cleo" is RFC Contributor at global scope
Then "cleo" may propose in "ohm/model" and "acme/specs"
@S3
Scenario: Collection Owner administers one collection only
Given "dan" is Owner at collection "ohm/features"
Then "dan" may graduate, mark-reviewed, and manage membership in "ohm/features"
But "dan" may not change "ohm" project settings
And "dan" may not act on entries in "ohm/model"
@S3
Scenario: Project Owner administers all collections and may create more
Given "eve" is Owner at project "ohm"
Then "eve" may manage membership in "ohm/model" and "ohm/features"
And "eve" may edit "ohm" project settings
And "eve" may create a new collection in "ohm"
@S3
Scenario: Most-permissive union — the higher grant wins
Given "fay" is RFC Contributor at collection "ohm/model"
And "fay" is Owner at project "ohm"
Then "fay" acts as Owner in "ohm/model"
@S3
Scenario: No negative override — a child cannot subtract a parent grant
Given "gil" is RFC Contributor at project "ohm"
Then there is no control to remove "gil" from "ohm/model" while keeping the project grant
And "gil" can propose in "ohm/model"
@S3
Scenario: A granted account with no scope role sees only public content
Given "hana" has a granted deployment account but no global, project, or collection role
Then "hana" may read public collections under the §6.1 anonymous-read contract
But "hana" is not offered the propose action anywhere
And gated projects and collections do not appear for her
```
## C.2 Invitation — who may invite whom, at which scope
```gherkin
Feature: Inviting users to a scope role
As an Owner of a scope
I want to grant Owner or RFC Contributor at my scope or any scope beneath it
So that collaborators get exactly the reach they need
@S4
Scenario: Project Owner invites at project scope (covers all collections)
Given "eve" is Owner at project "ohm"
When "eve" invites "ivy" as RFC Contributor at project "ohm"
Then a membership row is written at scope project "ohm"
And "ivy" receives a §15 notification naming the project and role
And "ivy" may propose in every collection of "ohm"
@S4
Scenario: Owner invites at a specific collection
When "eve" invites "jo" as RFC Contributor at collection "ohm/features"
Then a membership row is written at scope collection "ohm/features"
And "jo" may propose in "ohm/features" but not "ohm/model"
@S4
Scenario: Invitation reach is bounded by the inviter's scope
Given "dan" is Owner at collection "ohm/features"
Then "dan" may invite users to roles in "ohm/features"
But "dan" is not offered the control to invite at project "ohm" or global scope
@S4
Scenario: RFC Contributors do not manage membership
Given "ben" is RFC Contributor at project "ohm"
Then "ben" may propose and create collections in "ohm"
But "ben" is not offered any invite control (membership is an Owner capability)
@S4
Scenario: The invite UI offers no grant-at-parent-revoke-at-child option
Given "eve" is Owner at project "ohm"
When "eve" opens the invite control for "ivy" at project "ohm"
Then she may choose role Owner or RFC Contributor and scope project or a single collection
But there is no option to grant at "ohm" and exclude a child collection
@S4
Scenario: Re-inviting at a broader scope supersedes the narrower grant
Given "jo" is RFC Contributor at collection "ohm/features"
When "eve" invites "jo" as RFC Contributor at project "ohm"
Then "jo" has the role across all of "ohm"
And the redundant collection-scope row is removed or shown as subsumed
@S4
Scenario: A pending deployment account cannot be granted write
Given "kim" has permission_state "pending" at the deployment
When "eve" invites "kim" as RFC Contributor at project "ohm"
Then the grant is recorded but confers no write capability until "kim" is granted at the deployment (§6)
```
## C.3 Empty-state experiences
```gherkin
Feature: Empty states at each tier
As a viewer of a tier with nothing in it yet
I want a clear, role-appropriate empty state
So that I know whether there is an action to take or simply nothing to see
@S5
Scenario: Global directory with no projects — Owner
Given a deployment with no projects
And "root" is Owner at global scope
When "root" lands on "/"
Then she sees an empty directory with a "Create your first project" call to action
@S5
Scenario: Global directory with no visible projects — non-owner
Given a deployment whose only projects are gated
And "vee" is a granted account with no roles
When "vee" lands on "/"
Then she sees an empty directory with no create action
And a note that there is nothing shared with her yet
@S4
Scenario: Project with no collections — project Owner
Given project "ohm" with no collections
And "eve" is Owner at project "ohm"
When "eve" lands on "/p/ohm/"
Then she sees an empty collection directory with a "Create your first collection" call to action
And the action lets her choose a type and subfolder
@S4
Scenario: Project with no collections — RFC Contributor without create rights
Given project "ohm" with no collections
And "ben" is RFC Contributor at collection scope elsewhere only
When "ben" lands on "/p/ohm/"
Then he sees an empty collection directory with no create action
@S4
Scenario: Collection with no entries — a contributor
Given collection "ohm/model" with no entries
And "ada" is RFC Contributor at collection "ohm/model"
When "ada" lands on "/p/ohm/c/model/"
Then she sees an empty catalog with a "Propose the first entry" call to action
@S2
Scenario: Collection with no entries — an anonymous reader
Given a public collection "ohm/model" with no entries
When an anonymous visitor lands on "/p/ohm/c/model/"
Then they see an empty catalog with no propose action and a sign-in prompt
@S1
Scenario: Single-collection project skips the directory
Given project "ohm" with exactly one visible collection "model"
When a visitor lands on "/p/ohm/"
Then they are redirected to "/p/ohm/c/model/"
@S1
Scenario: Single-project deployment skips the directory
Given a deployment with exactly one visible project "ohm"
When a visitor lands on "/"
Then they are redirected to "/p/ohm/"
```
---
# Part D — Amendments to the original §22 draft
Applied in place when §22 is rewritten; listed here as the change surface.
- **§22 preamble / §22.1.** "A deployment hosts N projects, each a corpus" →
"a deployment hosts N **projects**, each owning one content repo and holding
N **RFC collections**, each collection a typed corpus." Isolation moves to the
collection grain.
- **§22.2 Registry.** `projects.yaml` declares projects with one `content_repo`
each (no per-collection `content_repo`). New: collections are declared by
`.collection.yaml` manifests inside the content repo; the mirror reads them.
In-app create-project / create-collection actions wrap bot commits.
- **§22.3 Content repos.** "One per project" (not per collection); collections
are subfolders within it.
- **§22.4 / §22.4a-c.** Slug is unique **per collection**. `type`,
`initial_state`, and `unreviewed` are **collection** properties (re-homed from
"project"). Unchanged otherwise.
- **§22.5 Visibility.** Applies at **both** project and collection. A collection
defaults to its project's visibility and may narrow it; reading/writing a
collection requires passing both gates.
- **§22.6 Membership and roles → the unified model (Part B).** Replace the three
`project_*` roles with `{owner, contributor}` at `{global, project,
collection}` via a polymorphic `memberships` table. Add **§22.6a** = the
Part C scenarios.
- **§22.7 Composition.** Four-layer most-permissive union (global → project →
collection → per-entry); no negative override.
- **§22.9 / §22.10 Branding & routing.** Routes gain the collection segment:
`/p/<project>/c/<collection>/…`. `GET /api/deployment` lists visible projects;
add `GET /api/projects/:id` (lists visible collections + project settings) and
`GET /api/projects/:id/collections/:cid` (collection settings incl. `type`).
- **§22.11 Notifications / §22.13 migration / §5 amendments.** `project_id`
becomes `collection_id` on every entry-scoped row (the corpus grain is now the
collection); a separate `project_id` exists only on the `collections` table
and project-scoped rows. The §22.13 default project gains a default collection
(§A.6 below).
---
# Part E — Revised slicing plan (the roadmap re-slot)
**Strategy (session 0072, decided on corrected facts).** The two-tier model is
shipped end-to-end (v0.39.0): migration 028 keyed entries `(project_id, slug)`;
v0.35.0 shipped `/p/<project>/` routing + live `/p/<project>/e/<slug>` URLs;
v0.37.0/0.38.0 shipped per-project read + propose. Inserting the third tier is
therefore an **evolution of a shipped system**. The chosen mapping **adds a
collection grain *beneath* today's project** — the shipped `projects` table
stays the grouping tier (it already owns `content_repo`, where §A.2 wants it),
a new `collections` table holds the per-corpus fields, and entries re-key to the
finer `(collection_id, slug)`.
**Slicing principle (session 0072): every slice ends in a *usable* deployment,
and declares the Part C scenarios it makes pass** (its `@S<n>` tag). "Usable"
means the deployment runs and either gains a capability or provably loses none
(N=1 unchanged). A slice is done when its `@S<n>` scenarios are green.
- **Landed, unchanged (v0.39.0):** M1M2, M3-backend Plan A **and** Plan B
(read+propose, mig 028), M3-frontend (`/p/<project>/` routing), §22.13
re-stamp. None of this is rebuilt; it is *evolved* by the slices below.
- **S1 — The collection grain exists (invisible default).** Migration 029 +
backend threading + the default-routing redirect, shipped **together** (they
are coupled — renaming `project_id``collection_id` breaks every reader until
the code is threaded, so a green tree needs both). Migration 029
(`029_collections.sql`): (1) add a `collections` table
`(id, project_id, type, subfolder, initial_state, visibility, name,
registry_sha)`; (2) move the per-corpus fields (`type`, `initial_state`,
visibility) **down** from `projects` (leaving it `(id, content_repo,
visibility, name, tagline, theme, enabled_models, …)`); (3) create one default
collection per project (id `default`, `subfolder` = repo root); (4) re-key
every entry-scoped table `(project_id, slug)``(collection_id, slug)` via the
`028_project_scoped_keys.sql` rebuild pattern (`__new`, copy, drop, rename,
FK-off + `foreign_key_check`); (5) generalize `project_members`
`memberships(scope_type ∈ {project, collection}, …)`, collapsing the role enum
(§B.3). Then thread `collection_id` through `app/auth.py` / `app/projects.py`
/ `app/cache.py` / the `api_*` writers, and **308** `/p/<project>/e/<slug>`
`/p/<project>/c/<default>/e/<slug>`. **Usable end-state:** the deployment runs
exactly as before, now with a real collection layer and one extra path segment.
**Completes:** `@S1` (the single-collection / single-project redirect skips).
- **S2 — Create & navigate a second collection.** *(Shipped v0.41.0.)* Teach the
registry mirror to
read `.collection.yaml`; add the bot-commit-wrapped **create-collection**
endpoint (authorized by existing deployment owner/admin for now — the scoped
role surface lands in S3); the project collection-directory at `/p/<project>/`;
collection-scoped propose/serve under `/p/<project>/c/<collection>/`.
**Usable end-state:** an admin creates a `bdd` collection beside the document
one and it is navigable + proposable. **Completes:** `@S2` (anonymous reader of
an empty collection catalog).
- **S3 — Scope-role enforcement.** *(Shipped v0.42.0.)* The four-layer
most-permissive resolver (§B.2) over `{owner, contributor}` grants at
`{global, project, collection}` (migration 030 adds the `global` scope_type),
with grants applied administratively (DB / the Owner-authorized create
surface); every write gate re-checked under the collection axis. **Plus the
operator's S3 visibility requirements:** collection-grain visibility is
enforced — a `gated` collection is hidden from the public (404, omitted from
the directory) yet visible to scope-role contributors; a collection's
visibility may be set only as strict or stricter than its project's
(`public` < `unlisted` < `gated`). **Keystone reconciliation (session 0076):**
§B.1/§B.3's literal "deployment contributor = global RFC Contributor"
contradicted the C.1 "hana" scenario and the M2 implicit-public baseline;
resolved as — a plain granted account is a granted *account*, not a
write-everywhere global role; "global RFC Contributor" is an explicit
`scope_type='global'` grant; the implicit-public write baseline is
grandfathered onto the migration-seeded `default` collection only (N=1
preserved). *Flag for the SPEC merge (S6): reinterprets §B.1/§B.3.* **Usable
end-state:** a user granted RFC Contributor at a scope can contribute across
exactly that subtree, Owners administer their subtree, and a collection can be
hidden from the public. **Completes:** `@S3` (all of C.1 — role usage,
inheritance, union, no-negative-override).
- **S4 — Invitation surfaces + role-aware empty states.** *(Shipped v0.43.0.)*
The invite UI
(Owner-only) granting Owner/RFC Contributor at a scope or any scope beneath it,
with §15 notifications and the broader-scope-supersedes rule; the
create-first-collection / propose-first empty states keyed to the actor's role.
Modelled as a **direct grant** to an existing account looked up by email (the
C.2 scenarios write the membership row immediately and §15-notify an existing
user — no accept round-trip; inviting a not-yet-account email is out of S4
scope, handled by the admin-create-invite path). **Usable end-state:** an Owner
invites collaborators at the right scope from the UI. **Completes:** `@S4` (all
of C.2 — invitation; plus the project/collection empty states C3.3C3.5).
- **S5 — In-app create-project + the global directory.** *(Shipped v0.44.0.)*
The global-Owner
**create-project** action (bot provisions a Gitea content repo + commits to
`projects.yaml`); the deployment directory empty states. Modelled as a
global-Owner gate (`auth.can_create_project`: a deployment owner/admin or an
explicit `scope_type='global'` Owner grant) over `POST /api/projects`; the
content repo defaults to `<id>-content` and is seeded with a `README.md` so
`main` exists. The deployment payload gains `viewer.can_create_project` +
`default_project_readable` so the directory renders the role-aware empty state
rather than bouncing into an unreadable/absent default. **Usable end-state:**
a global Owner stands up a new project end-to-end from the UI. **Completes:**
`@S5` (the global-directory empty states C3.1C3.2).
- **S6 — Type modules, membership lifecycle, hardening, SPEC merge.** Per-type
frontmatter + surfaces selected on the **collection's** `type`; request-to-join
+ cross-collection inbox; per-collection `enabled_models`; the registry +
manifest format in `docs/DEPLOYMENTS.md`; two-project / multi-collection e2e;
the §20.4 changelog + upgrade-steps; the SPEC merge (Part A applied, Part D in
place). **Usable end-state:** the model is fully realized and merged into
`SPEC.md`. **Completes:** type-specific scenarios (added in S6, beyond Part C's
role focus).
- *Shipped in S6 core (v0.45.0):* the SPEC merge, per-collection
`enabled_models`, the type-driven entry noun (§22.4a item 2).
- *Shipped as the S6 remainder (v0.46.0):* request-to-join + the
cross-collection inbox (§22.8).
- *Spec'd, not yet built — the last S6 item:* the per-type **frontmatter
schemas** (§22.4a item 1) and **surfaces** (§22.4a item 3, the
`specification` release-planning + `bdd` scenario/coverage views). The
discovery/spec pass + BDD scenarios + slicing (S7aS7c) are in
[`2026-06-06-per-type-surfaces.md`](./2026-06-06-per-type-surfaces.md).
### Slice → scenario index (the inverse of the `@S<n>` tags)
| Slice | Usable thing it ships | Completes (`@S<n>`) |
|---|---|---|
| **S1** | collection grain + default + redirects; N=1 unchanged | C3.7, C3.8 (`@S1`) |
| **S2** | create + navigate + propose a 2nd collection | C3.6 (`@S2`) |
| **S3** | scope-role enforcement across global/project/collection | C1.1C1.8 (`@S3`) |
| **S4** | invitation UI + role-aware empty states | C2.1C2.7, C3.3C3.5 (`@S4`) |
| **S5** | in-app create-project + global directory | C3.1, C3.2 (`@S5`) |
| **S6** | type surfaces, lifecycle, hardening, SPEC merge | type-specific (new) |
Each slice is a candidate single session: it lands a usable deployment and a
runnable acceptance gate (`--tags @S<n>`). **S1 is the natural first session**
the coupled migration 029 + threading + redirect, sized as one usable increment
(answering the in-session question: bundled, it is right-sized, not too much).
## E.1 (= §A.6) Migration — the default collection (the N=1 case)
A deployment on the shipped two-tier schema (v0.39.0) is migrated by 029 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 §22.13 step 1).
2. A **default collection** (`id='default'`, `subfolder` = repo root) is created
per project, inheriting that project's `type` / `initial_state` / visibility;
those fields are then dropped from `projects`.
3. Every entry-scoped `project_id` row is re-keyed with the default
`collection_id` (PK `(project_id, slug)``(collection_id, slug)`).
4. `project_members` rows migrate to `memberships(scope_type='collection')` on
the default collection, role-collapsed (§B.3).
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.
## E.2 Scope of the first implementation pass
Per the launch ask — "we don't need to get all permissions right yet, just have
Owner at all levels, and RFC Contributor at the global, project, and RFC
collection level" — the **role surface** this pass implements is exactly
`{owner, contributor}` × `{global, project, collection}` (Part B), plus the
unchanged per-entry layer. `viewer`, the owner/admin split, request-to-join
nuances, and per-type role labels are deferred (§B.1 note).
@@ -0,0 +1,473 @@
---
slug: 2026-06-06-configurable-collection-metadata
title: "Configurable Collection Metadata"
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-06'
graduated_at: null
graduated_by: null
owners:
- ben.stull@wiggleverse.org
arbiters: []
tags:
- solution-design
---
# Solution Design: Configurable Collection Metadata (clean-doc tagging)
| | |
| --- | --- |
| **Author(s)** | Ben Stull |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.6 |
| **Source artifacts** | Reference modeled: retired **BDD Release Planner** (`wiggleverse/wiggleverse-ecomm-bdd-release-planner-app`, RETIRED 2026-06-04) · Related: [`2026-06-05-three-tier-projects-collections.md`](./2026-06-05-three-tier-projects-collections.md) (§22) · Corpus: ecomm Shopify-modeled BDD (`wiggleverse-ecomm-meta/research/shopify`, ~1,238 scenarios) · **Supersedes:** [`2026-06-06-per-type-surfaces.md`](./2026-06-06-per-type-surfaces.md) |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-06 | v0.1.0 | Initial draft from discovery session OHM-0079.0 | Ben Stull |
| 2026-06-06 | v0.1.1 | Value-only Executive Summary; add Pain Points | Ben Stull |
| 2026-06-06 | v0.1.2 | Business Outcomes restated as business (adoption/diversity); Business Use Cases → solution-agnostic | Ben Stull |
| 2026-06-06 | v0.1.3 | Supersede per-type-surfaces draft (harvest patterns; bdd coverage future; §22.4a amendment); split Business Actors / Product Personas | Ben Stull |
| 2026-06-06 | v0.1.4 | Two-part restructure: §1 Business Context (solution-agnostic, 1.11.9) + §2 Solution Proposal; renumber | Ben Stull |
| 2026-06-06 | v0.1.5 | Move Business Actors to §1.3 (define roles before Problem/Pain reference them) | Ben Stull |
| 2026-06-06 | v0.1.6 | §7.1 execution convention — each slice is its own writing-plans→executing-plans coding session, plans just-in-time | Ben Stull |
---
## 1. Business Context
*The business lens — solution-agnostic throughout. No mechanism is proposed until §2.*
### 1.1 Executive Summary
A deployment's corpus is only as valuable as the ability of the people running it to prioritise it, navigate it, and act on it — and as valuable as the downstream tools that can read structured signal out of it. Today that value is stranded: operators and contributors can't rank what matters or find content by what matters, and the tools meant to plan and build from the corpus have nothing structured to consume. The value at stake is **lower-friction corpus planning** for operators and contributors, **broader adoption** by teams whose document types the platform couldn't previously serve, and **a corpus external tooling can consume without bespoke glue**. *(Value summary; the solution is proposed in §2.)*
### 1.2 Background
The framework hosts RFC standardization for multiple deployments. One deployment hosts the ecomm BDD corpus — ~1,238 Shopify-modeled scenarios, one markdown file per scenario, slugged by feature ID (`DD-FF-NNNN-slug`). A standalone **BDD Release Planner** previously let operators search that corpus, attach metadata (priority P0P3, owner, status), cluster scenarios into named releases, and emit each release as a roadmap phase. §22 (three-tier projects/collections) absorbed the planner's *corpus hosting* into rfc-app (the corpus now runs as a `bdd` project on the RFC deployment) and the planner was retired — but its *annotation* half (priority/tags on scenarios, filtering, bulk assignment) was never rebuilt. Teams evaluating rfc-app for *other* document types often need structured attributes (a priority, a status, domain tags) the platform can't yet express — so they go elsewhere.
### 1.3 Business Actors / Roles
Real-world roles, **solution-agnostic** — they exist whether or not rfc-app does. They are defined here, before the Problem (§1.4) and Pain Points (§1.5) reference them; the Business Use Cases (§1.9) are about these roles, and the Product Personas (§3) map onto them.
| Role | Responsible for (in the business) |
| --- | --- |
| Standards owner | Owns an organization's RFC / standards / requirements process; decides what's tracked and how |
| Release planner | Decides what work belongs in upcoming releases |
| Requirements author | Proposes and curates the requirements (e.g. BDD scenarios) |
| Requirements consumer | A person or downstream tool that plans or builds from the requirements |
| Reader | Anyone navigating the corpus to find what's relevant to them |
### 1.4 Problem Statement
rfc-app cannot express or surface structured signal about its content. Tags are free-form strings with no filtering; there is no notion of priority or any other collection-defined attribute; the catalog is a flat list; and what little metadata exists is mixed into the top of every document. As a result, a corpus cannot be prioritised, navigated by attribute, planned in bulk, or cleanly consumed by downstream tools — and teams whose workflows depend on such attributes cannot adopt the platform at all.
### 1.5 Pain Points
| # | Pain | Who feels it | Cost / frequency today |
| --- | --- | --- | --- |
| PP-1 | Scenarios carry no priority, so triage and planning happen off-platform, in spreadsheets and memory | Release planner, contributor | Every planning cycle; signal lives off-platform and goes stale |
| PP-2 | The catalog is a flat, unfilterable list — at ~1,200 scenarios, "show me the P0 checkout scenarios" is impractical | Reader, release planner | Every browse/triage; finding the right work is slow and error-prone |
| PP-3 | Tags are free-form with no filtering payoff, so they're decorative and go unmaintained | Contributor | Ongoing; the one existing affordance rots |
| PP-4 | Annotating many scenarios means opening many PRs, so bulk planning has no home in the tool | Release planner | Every batch; the core planning gesture is effectively impossible |
| PP-5 | rfc-app metadata clutters the top of every document, hurting readability and making the corpus awkward to consume cleanly | Reader, downstream consumer | Every read; every downstream integration |
| PP-6 | Downstream tools have no structured signal to read — the retired planner's capability left a gap | Downstream consumer | Continuous since the planner's retirement |
| PP-7 | Teams whose document types need structured attributes can't model them, so they don't adopt rfc-app | Prospective adopter (org/team) | Every evaluation that ends in "not yet" |
### 1.6 Targeted Business Outcomes
Business outcomes for rfc-app as a platform — adoption, reach, and diversity of use — **not** solution outputs. (Whether documents carry a priority is a solution output, tracked as a slice's Definition of Done in §7, not here.)
| Outcome | Success metric | Baseline → Target | Guardrail (must not regress) | How / when measured |
| --- | --- | --- | --- | --- |
| Teams blocked by missing structured attributes now adopt rfc-app | # organizations on rfc-app; # active users | internal deployments only → external orgs onboard | existing deployments don't churn | deployment registry + usage analytics; quarterly |
| The platform hosts a wider variety of workflows and document types | # distinct document/collection types & field schemas in use | today's handful → broader mix | existing types' experience unchanged | type/schema census; quarterly |
| Corpus planning happens on-platform rather than in side tools | share of prioritisation/planning done in rfc-app vs spreadsheets | largely off-platform → on-platform | — | operator interviews + usage signals; quarterly |
### 1.7 Scope (business)
- **In scope:** the corpus can carry per-item importance and categorisation; people can find items by those attributes; the signal is captured durably and is consumable by other people and tools; teams with new document types can express the attributes their workflow needs.
- **Out of scope (business):** deciding *what* a given deployment's priorities or categories should be (that's the deployment's editorial choice); release sequencing and ship tracking as a business process (stays a downstream/operator concern).
- **Non-goals:** modelling "releases" as a first-class business object inside the platform.
*(Solution-specific scope/non-goals are in §2.)*
### 1.8 Assumptions · Constraints · Dependencies
- **Assumptions:** git remains the content source of truth and downstream consumers can read the corpus from git; the BDD grain is one markdown file per scenario (already true for the ecomm corpus).
- **Constraints:** rfc-app is a framework hosting multiple deployments — any change must be **mechanical and non-breaking**, with §20 changelog/upgrade-steps; the hard secrets rule (§6.3) holds; edits must respect scope-role authorization (§22 Part B / S3); the §22.4a "engine unchanged" rule holds (INV-8).
- **Dependencies:** the S3 scope-role resolver (`auth.effective_scope_role`); the existing git write-through used by `edit-meta` (§9.5); the §22 collection model; the binding `SPEC.md` §22.4a contract, which §2's solution amends (§7 SLICE-0).
### 1.9 Business Use Cases
Solution-agnostic: what an actor (§1.3) wants to accomplish, *why* (value), and what *success* looks like — **no reference to any product**. Each could be satisfied by a person by hand before any software. Form: "As a … I can … so that …".
**BUC-1 — As a release planner, I can prioritise the requirements in a body of work, so that I can decide what belongs in upcoming releases.**
```gherkin
Scenario: BUC-1 — Prioritise to plan releases
Given a body of requirements of varying importance
When the planner weighs which matter most
Then they hold a ranking of those requirements by importance
And can decide a release's contents from it
```
- **Acceptance:** the planner can select and justify the next release's contents from the relative importance of the work.
**BUC-2 — As a planner facing a large body of requirements, I can organise and triage it within a normal working session, so that planning actually gets done rather than deferred or improvised.**
```gherkin
Scenario: BUC-2 — Triage at scale
Given more requirements than can be weighed one at a time
When the planner ranks and groups them in bulk
Then the body of work reflects those decisions without per-item drudgery
```
- **Acceptance:** a planner moves from an unsorted corpus to a prioritised plan in one sitting.
**BUC-3 — As a team, I want the importance and categorisation of our requirements captured durably and shareably, so that other people and tools can plan from it without re-deriving it.**
```gherkin
Scenario: BUC-3 — Durable, shareable signal
Given requirements that have been weighed and categorised
When someone or something else needs to plan from them
Then they can read what matters and why without asking the original author
```
- **Acceptance:** a second party — person or tool — can pick up the work and plan from it unaided.
**BUC-4 — As a team with a specialised body of documents, I can capture the attributes that make them actionable (importance, status, category), so that I can manage that work the way my domain requires.**
```gherkin
Scenario: BUC-4 — Manage a domain's work on its own terms
Given documents whose usefulness depends on domain-specific attributes
When the team records and works with those attributes
Then they can run their workflow with the distinctions it depends on
```
- **Acceptance:** the team can capture and act on the distinctions their domain requires — success is them choosing to manage the work this way.
**BUC-5 — As someone consuming a large corpus, I can find the items that matter to my current purpose, so that I act on the right things instead of wading through everything.**
```gherkin
Scenario: BUC-5 — Find what matters
Given a large body of items
When the consumer looks for the important ones for their task
Then they can locate them quickly
```
- **Acceptance:** a person narrows a large corpus to the relevant, important subset for their task.
---
## 2. Solution Proposal
**The solution is to build it into rfc-app.** Give every collection a small, declared **field schema** (in its `.collection.yaml`) so it can carry structured metadata — priority, tags, and any custom fields the deployment defines. Store each entry's values in a **clean sidecar** file so the document body stays pure prose. rfc-app then **renders those fields as forms, filters the catalog by them (faceted, with counts), and lets authorized users tag in single and bulk gestures** committed straight to git; downstream tools read the values from the sidecars directly. It is one generic mechanism — tags and priority are just *fields* — not per-type special-casing and not a bespoke "release" entity.
**Why a software solution (and not a manual one).** A non-build alternative — operators maintaining priorities/tags in a shared spreadsheet — was considered and rejected: it leaves the corpus unfilterable in-tool (PP-2), keeps documents and the side-sheet out of sync, produces no durable git-readable signal for downstream tools (PP-5/PP-6), and does nothing for the adoption outcome (§1.6, PP-7). The value only lands if the structure lives with the content.
**Solution-specific scope.** *Out:* release ordering, ship status, roadmap emission, the `specification` release-planning surface — all downstream, reading sidecars from git. In-app management of field definitions (edit `.collection.yaml` in git for v1); corpus-wide tag rename/merge/delete; sub-document grain; a whole-corpus export endpoint. *Future (recorded, not v1):* a **bdd coverage surface** — a `verifies`-style **`ref` field type** plus a read-derived view mapping features to the spec sections they exercise (harvested from the superseded per-type-surfaces draft); deferred pending §9 Q4. This solution **amends the binding `SPEC.md` §22.4a contract** (§7 SLICE-0).
*(The Product and Engineering sections below — §§37 — elaborate this build. They would be replaced by an operational plan if the chosen solution were non-software.)*
---
## 3. Product Personas
rfc-app's user types — each an embodiment of one or more Business Roles (§1.3). The Product Use Cases (§4) are about these personas.
| Product persona | In rfc-app | Maps to business role(s) |
| --- | --- | --- |
| Collection Owner | scope-role Owner; declares the collection's `fields:` schema (edits `.collection.yaml`) | Standards owner |
| Contributor | scope-role contributor; sets metadata (single + bulk), proposes/curates entries | Requirements author; Release planner |
| Reader | viewer; browses and filters the catalog | Reader |
| Downstream consumer | an external system reading sidecars + `.collection.yaml` from git | Requirements consumer |
## 4. Product Use Cases
```gherkin
Scenario: PUC-1 — Set priority/tags on a scenario (realizes BUC-1, BUC-4)
Given I am a Contributor viewing a scenario whose collection defines priority and tags
When I choose P0 in the priority control and add the tag "checkout"
Then the metadata panel reflects P0 and the checkout tag
And the change is committed directly to the scenario's sidecar
Scenario: PUC-2 — Bulk tag/untag from the catalog (realizes BUC-2)
Given I have multi-selected several scenarios in the catalog
When I choose "Set priority P1" from the bulk action bar
Then every selected scenario shows P1
And the bulk change is one commit
Scenario: PUC-3 — Filter the catalog by facet (realizes BUC-5, BUC-1)
Given the left pane shows faceted filters generated from the collection schema
When I check Priority P0 and tag "checkout"
Then the catalog shows only scenarios matching both
And each facet value shows its result count
Scenario: PUC-4 — A Collection Owner declares fields (realizes BUC-4)
Given a Collection Owner edits .collection.yaml to add a priority enum field
When the collection is re-ingested
Then the priority filter and the priority form control appear automatically
Scenario: PUC-5 — Migrate a collection to clean docs (product-only; enables BUC-3)
Given a collection whose docs still carry top-of-doc frontmatter
When the operator runs the frontmattersidecar migration
Then each doc body becomes pure prose and a sidecar holds its metadata
And rfc-app reads the collection identically before and after
Scenario: PUC-6 — A malformed entry is visibly fixable (realizes BUC-3)
Given a stored entry whose metadata fails its collection's schema
When the catalog renders
Then the entry still loads (read never hard-fails)
And it is flagged "malformed metadata" so a Contributor can fix it
```
## 5. UX Layout
### 5.1 Screen: Catalog (left pane) (serves PUC-3, PUC-6)
- **Purpose:** browse and filter a collection's entries.
- **Layout (top → bottom):** full-text search (existing); **faceted filter groups** (one per schema field + state): each a collapsible group with per-value **result counts** and multi-select checkboxes; `tags`-type fields include a "filter values…" search box to stay usable at 30+ values.
- **States:** happy: facets with counts · empty: "no entries match" + clear-filters · loading: skeleton facets · error: retry · **malformed:** entries failing their schema carry a fixable marker (parallel to §22.4c `unreviewed`) and are filterable.
### 5.2 Screen: Scenario detail — metadata panel (serves PUC-1)
- **Purpose:** view/edit one entry's metadata.
- **Layout:** one control per schema field — `enum` → single-select; `tags` → removable chips + add-tag input (with existing AI suggest); `text` → text input. The body renders below as pure prose; metadata never appears inline.
- **States:** read (no edit role) shows values · edit (authorized) shows controls · saving: spinner · error: field-level validation message.
### 5.3 Screen: Catalog — bulk action bar (serves PUC-2)
- **Purpose:** apply a field value to many entries at once.
- **Layout:** selecting ≥1 row reveals a sticky bar: "*N* selected · Set priority ▾ · Add tag ▾ · Remove tag ▾ · Clear". Applying commits once.
- **States:** none selected: hidden · applying: progress · partial failure: toast naming entries that failed validation, others applied.
## 6. Technical Design
### 6.1 Invariants
- **INV-1:** The sidecar (`<slug>.meta.yaml`) is the source of truth for entry metadata; `cached_rfcs` is a derived index, fully rebuildable from git.
- **INV-2:** A document body (`.md`) never contains rfc-app metadata once migrated; metadata lives only in the sidecar.
- **INV-3:** Reading a collection never hard-fails on bad metadata — an invalid value surfaces as a warning, the entry still loads, and the catalog flags it (§5.1).
- **INV-4:** Metadata writes are authorized by scope-role (contributor+ on the collection) and validated at the write boundary; content-body edits keep their existing PR-review path.
- **INV-5:** A collection with no `fields:` block behaves exactly as today (free-form `tags` only). The §22.13 generated **default collection is `document`** with no fields → **N=1 deployments see zero change**.
- **INV-6:** Dual-read: parser reads the sidecar if present, else legacy top-of-doc frontmatter, with identical resulting in-memory records.
- **INV-7:** Unknown / forward-compat keys in a sidecar **ride along untouched** — never dropped on read or rewrite, never reported as malformed.
- **INV-8:** **Engine unchanged** (§22.4a) — additive and read-mostly; never forks the content write path, the propose→branch→PR→graduate lifecycle, threads/flags/chat, or the storage model. Metadata edits reuse the existing `edit-meta` git write-through.
### 6.2 High-level architecture
```mermaid
flowchart LR
subgraph Git[content repo]
CY[.collection.yaml<br/>fields: schema]
MD[slug.md<br/>prose body]
SC[slug.meta.yaml<br/>values]
end
CY --> ING[ingest / parser<br/>lenient, type-agnostic]
MD --> ING
SC --> ING
ING --> VAL[metadata_schema.validate<br/>advisory at read]
VAL --> DB[(cached_rfcs<br/>values + facet counts + malformed)]
DB --> API[API: schema · list+filter · facets · edit]
API --> FILT[left-pane faceted filters]
API --> PANEL[detail metadata panel]
API --> BULK[bulk select bar]
PANEL -->|validate + direct commit| SC
BULK -->|validate + 1 commit| SC
SC -.read from git.-> CONS[downstream consumers]
```
- **ingest/parser** — reads `.collection.yaml` schema + sidecars (or legacy frontmatter), stays lenient/type-agnostic (INV-7); rebuilds `cached_rfcs`; never authoritative.
- **`metadata_schema.validate(values, fields) → [problems]`** — the one place that knows a collection's required/forbidden fields and each field's shape (modeled on `registry.py`). Advisory at ingest (warn + malformed flag, INV-3); enforced at the write boundary (INV-4).
- **API** — serves the schema, filtered lists with facet counts + malformed flag, and metadata edits; never writes metadata anywhere but the sidecar.
### 6.3 Data model & ownership
| Entity | Owned by | Key fields | System of record |
| --- | --- | --- | --- |
| Collection field schema | Collection Owner | `fields: {name → {type, values?, label}}` in `.collection.yaml` | git |
| Entry metadata values | Contributor | sidecar `<slug>.meta.yaml`: lifecycle + schema fields + forward-compat keys (INV-7) | git (sidecar) |
| Derived index | ingest | per-entry values + facet aggregations + `malformed` flag | `cached_rfcs` (SQLite, derived) |
**Field types (v1):** `enum` (single-select; controlled by required `values:`), `tags` (multi-value; free-form unless `values:` given), `text` (free string). **Future:** `ref` (a typed cross-entry link — basis for the deferred bdd `verifies`/coverage surface; §2, §9 Q4). Unknown types ignored with a warning.
**Sidecar example:**
```yaml
slug: 01-01-0001-view-today-s-key-performance-metrics-at-a-glance
title: View today's key performance metrics at a glance
state: active
owners: [ben.stull]
priority: P1
tags: [dashboard, analytics]
owner: hasan
```
### 6.4 Interfaces & contracts
- **`GET …/collections/<c>`** — out: collection incl. `fields` schema.
- **`GET …/collections/<c>/rfcs`** — in: filter params (`?priority=P0&tags=checkout&state=active`; OR within a field, AND across fields; `?malformed=true`) · out: entries with values + per-entry `malformed` + `facets: {field → {value → count}}`. Errors: 400 unknown field.
- **`POST …/rfcs/<slug>/meta`** — in: `{field: value}` · effect: validate → write sidecar → direct commit → re-ingest. Errors: 403, 422.
- **`POST …/collections/<c>/meta/bulk`** — in: `{slugs, op: set|add|remove, field, value}` · out: `{applied, rejected}` · effect: validate → write N sidecars → one commit → re-ingest. Errors: 403, 422.
### 6.5 PerProduct-Use-Case design
#### PUC-2 — Bulk tag/untag
```mermaid
sequenceDiagram
actor U as Contributor
participant C as Catalog UI
participant A as API
participant V as metadata_schema
participant G as Git
participant D as cached_rfcs
U->>C: select rows, "Set priority P1"
C->>A: POST /meta/bulk {slugs, set, priority, P1}
A->>A: authz (scope-role)
A->>V: validate values vs schema
A->>G: write N sidecars, 1 commit
A->>D: re-ingest affected entries
A-->>C: {applied, rejected}
C-->>U: rows show P1; toast on any rejected
```
- **Implementation:** reuse the `edit-meta` git write-through, extended to target the sidecar and batch N files into one commit. Honors INV-1/INV-4/INV-8.
#### PUC-5 — Migration
- **Implementation:** a tool walks a collection; for each entry with legacy frontmatter it writes `<slug>.meta.yaml` and rewrites `<slug>.md` to the body only — one commit per collection, idempotent, preserving unknown keys (INV-7). Dual-read (INV-6) lets it run anytime; lazy migration converts stragglers on first metadata edit.
### 6.6 Non-functional requirements & cross-cutting concerns
- **Security & privacy:** edits gated by `auth.effective_scope_role`; no secrets in sidecars; git history records authorship.
- **Performance & scale:** facet counts from the derived DB; responsive at ~1.2k entries with dozens of tag values.
- **Availability & resilience:** bad metadata never blocks read (INV-3); failed re-ingest leaves git authoritative, recoverable by rebuild.
- **Observability:** log each metadata commit; warn-log + count schema-validation failures on ingest.
- **Accessibility:** facet groups and form controls keyboard-navigable; checkboxes labelled value + count.
### 6.7 Key decisions & alternatives considered
| Decision | Chosen | Alternatives | Why |
| --- | --- | --- | --- |
| Solution type | Build into rfc-app | Manual (shared spreadsheet) | Manual leaves corpus unfilterable, out of sync, no git-readable signal (§2) |
| Release modeling | Metadata only; releases downstream | First-class release entity | Operator pulled ordering/ship-status out of rfc-app |
| Tag system shape | One generic typed-field system | Releases first-class + simple tags; namespaced facets | Tags/priority/custom are all just fields |
| Schema model (D9) | Pure collection-config | Type-driven hard-coded schemas (per-type-surfaces draft) | Flexible, data-driven |
| Metadata storage | Sidecar per entry | Frontmatter; end-of-doc; index file; DB-only | Clean docs + git-visible + locality |
| Left-pane filtering | Faceted groups with counts | Flat facet chips | Scales to ~1.2k-scenario, many-tag corpus |
| Edit governance | Direct commit for authorized roles | PR per change | Bulk planning impractical via PR-per-toggle |
| bdd coverage (D10) | Future per-type surface over a `ref` field | Build now; drop | Valuable but not v1; needs Q4 |
### 6.8 Testing strategy
Unit: schema parsing (all types, missing block); sidecar round-trip incl. unknown-key preservation (INV-7); dual-read equivalence (INV-6); validation; malformed-flag; facet aggregation; bulk op (single commit, partial-rejection). Two-tier local-Docker→PPE for API + git write-through. "Tested" = PUC acceptance scenarios pass + migration proven idempotent and reversible-on-read.
### 6.9 Failure modes, rollback & flags
- **Invalid value committed out-of-band** → ingest warns + loads with the value flagged malformed (INV-3).
- **Re-ingest fails after commit** → git authoritative; full rebuild recovers.
- **Migration rollback:** dual-read keeps an un-/partly-migrated corpus working; the migration commit is revertible.
- **Feature flag:** inherently opt-in per collection (INV-5) — no global flag.
## 7. Delivery Plan
### 7.1 Approach / strategy
Amend the binding contract first, then build storage/compat, then schema, then read, then write. Each build slice is shippable and non-breaking.
**Execution convention.** Each slice is taken as **its own coding session**`writing-plans → executing-plans → verify → ship/deploy → merge + version bump` — in dependency order, with the slice's implementation plan written **just-in-time** at the start of that session, not up front (later slices' plans depend on the code earlier slices land). `brainstorming` ran once to produce this spec and recurs only if a slice proves the spec wrong. A slice's **Definition of Done** (§7.2) is the signal to advance the `Next /goal:` cursor to the next slice. SLICE-0 is doc-only (no implementation plan).
### 7.2 Slicing plan
#### SLICE-0 — Amend `SPEC.md` §22.4a (contract) → unblocks the rest
- **Depends on:** —
- **Definition of done:** §22.4a reframed — item 1 (entry schema) is **collection-configured sidecar fields**, not type-driven frontmatter; item 3 (type surfaces) deferred to a future design (bdd coverage recorded); per-type-surfaces draft marked superseded; §20 changelog. *Doc-only; no code.*
#### SLICE-1 — Sidecar storage + dual-read + migration → completes PUC-5, PUC-6
- **Depends on:** SLICE-0
- **DoD:** parser reads sidecar-else-legacy (INV-6), preserves unknown keys (INV-7); migration tool idempotent; existing collections load byte-identically; malformed flag derived; tests green.
#### SLICE-2 — Collection field schema + central validation → completes PUC-4
- **Depends on:** SLICE-1
- **DoD:** `.collection.yaml fields:` parsed; `metadata_schema.validate` advisory at read / enforced at write; schema served via the collection API; no-`fields:` collections unchanged (INV-5).
#### SLICE-3 — Faceted left-pane filtering (read) → completes PUC-3
- **Depends on:** SLICE-2
- **DoD:** list endpoint returns facet counts + honors filter params (incl. `malformed`); left pane renders faceted groups with counts + tag-value search; filters compose.
#### SLICE-4 — Single-entry metadata edit → completes PUC-1
- **Depends on:** SLICE-2
- **DoD:** detail panel renders schema controls; `POST …/meta` validates, direct-commits, re-ingests; scope-role gated (INV-4); lazy-migrates a legacy entry on first edit.
- **Carried from SLICE-1 (deferred there):** make the **write paths**
sidecar-aware — every site that today does `entry.parse(<slug>.md)` and
serializes back into the `.md` must read/write metadata via the sidecar so a
migrated (body-only) entry doesn't crash or re-grow frontmatter. The known
sites: graduation + claim + `_read_meta_entry` (`api_graduation.py`),
`mark_entry_reviewed` (`bot.py`), body-edit / accept-change wrappers
(`api_branches.py` `_wrap_body`/`_extract_body`), and the PR-replay wrappers
(`api_prs.py`). Only once these are sidecar-aware should the **operator
trigger** for `metadata.migrate_collection` (the Owner-gated migrate endpoint)
ship.
#### SLICE-5 — Bulk tag/untag → completes PUC-2
- **Depends on:** SLICE-3, SLICE-4
- **DoD:** multi-select + bulk bar; `POST …/meta/bulk` applies set/add/remove as one commit; partial-rejection reported.
### 7.3 Rollout / launch plan
Pre-v1, single production: ship slices in order; each minor bump carries §20 changelog + upgrade steps. Opt-in per collection (INV-5): a deployment adopts it only by declaring a `fields:` block and (optionally) running the migration.
### 7.4 Risks & mitigations
| Risk | L/I | Mitigation |
| --- | --- | --- |
| Amending binding §22.4a destabilises a shipped contract | M/M | SLICE-0 doc-only, reviewed; dual-read keeps runtime non-breaking; supersede note preserves rationale |
| Frontmatter→sidecar migration corrupts content | L/H | Dual-read; idempotent, revertible migration; body-byte-identity + unknown-key tests |
| Doubling file count (sidecars) clutters corpus | M/L | Docs stay clean; sidecars small/co-located |
| Direct-commit metadata edits bypass review | M/M | Scope-role gate (INV-4); content-body edits still PR'd; git audit trail |
| Facet aggregation slow at scale | L/M | Compute from indexed derived DB; measure at ~1.2k entries |
## 8. Traceability matrix
| Pain | Business UC | Product UC | Slice | Tests |
| --- | --- | --- | --- | --- |
| — (contract) | — | — | SLICE-0 | (doc review) |
| PP-5 | BUC-3 | PUC-5, PUC-6 | SLICE-1 | `test_dual_read_equiv`, `test_migration_idempotent`, `test_unknown_keys_preserved` |
| PP-7 | BUC-4 | PUC-4 | SLICE-2 | `test_schema_parse`, `test_validate` |
| PP-2 | BUC-5, BUC-1 | PUC-3 | SLICE-3 | `test_facet_counts`, `test_filter_compose` |
| PP-1, PP-3 | BUC-1, BUC-4 | PUC-1 | SLICE-4 | `test_single_meta_commit`, `test_authz` |
| PP-4 | BUC-2 | PUC-2 | SLICE-5 | `test_bulk_one_commit`, `test_partial_reject` |
| PP-6 | BUC-3 | (consumer reads git) | — | `test_sidecar_schema_stable` |
## 9. Open Questions & Decisions log
**Open**
| # | Question | Owner | Blocks |
| --- | --- | --- | --- |
| Q1 | Do downstream consumers read sidecars from git, via API, or both? (leaning git) | Ben | nothing v1 |
| Q2 | Ship `multi-enum` (multi-select controlled) in v1 or later? | Ben | SLICE-2 scope |
| Q3 | Exact §22.4a amendment wording + the future-surfaces home | Ben | SLICE-0 |
| Q4 | bdd coverage: `ref` field grammar + a coverage view honoring §22's no-cross-collection-join rule as hyperlinks | Ben | future surface |
**Resolved**
| # | Decision | Resolution | Date |
| --- | --- | --- | --- |
| D1 | Release behaviors | Out of rfc-app; downstream | 2026-06-06 |
| D2 | Tag system shape | Approach A — one generic typed-field system | 2026-06-06 |
| D3 | Metadata grain | Per entry (corpus already one file per scenario) | 2026-06-06 |
| D4 | Schema location | `.collection.yaml` `fields:` block | 2026-06-06 |
| D5 | Value storage | Sidecar `<slug>.meta.yaml`; doc body pure prose | 2026-06-06 |
| D6 | Left-pane filtering | Faceted groups with counts | 2026-06-06 |
| D7 | Edit governance | Direct commit for authorized roles; bulk = 1 commit | 2026-06-06 |
| D8 | Management scope | Deferred; edit `.collection.yaml` in git for v1 | 2026-06-06 |
| D9 | Schema model | Pure collection-config; not type-driven | 2026-06-06 |
| D10 | bdd coverage | Future per-type surface over a `ref` field; not v1 | 2026-06-06 |
| D11 | per-type-surfaces draft | Superseded; §22.4a to be amended (SLICE-0) | 2026-06-06 |
## 10. Glossary & References
- **Sidecar** — `<slug>.meta.yaml`, the per-entry metadata file that is the source of truth; keeps the `.md` body pure prose.
- **Field schema** — the `fields:` block in `.collection.yaml` declaring a collection's typed metadata fields.
- **Facet** — a schema field surfaced as a left-pane filter group with per-value counts.
- **Malformed metadata** — stored values that fail their collection's schema; flagged in the catalog, never a hard read failure (INV-3).
- **Downstream consumer** — an external tool that reads corpus metadata from git; rfc-app does not model releases.
- **References:** retired BDD Release Planner; superseded per-type-surfaces draft (`2026-06-06-per-type-surfaces.md`); §22 three-tier design; `SPEC.md` §7.1 (left-pane filter), §9.5 (edit-meta), §20 (versioning), §22.4a (per-type contract — to be amended), §22 Part B / S3 (scope-role).
```
+383
View File
@@ -0,0 +1,383 @@
---
slug: 2026-06-06-per-type-surfaces
title: "Per-Type Surfaces (superseded)"
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-06'
graduated_at: null
graduated_by: null
owners:
- ben.stull@wiggleverse.org
arbiters: []
tags:
- solution-design
---
# Draft spec — §22.4a per-type surfaces (the last S6 item)
> # ⛔ SUPERSEDED (2026-06-06)
>
> This draft is **superseded by**
> [`2026-06-06-configurable-collection-metadata.md`](./2026-06-06-configurable-collection-metadata.md),
> which reframes §22.4a item 1 as **collection-configured** metadata in
> **sidecars** (not type-driven frontmatter) and defers item 3's surfaces.
> Harvested into the successor: the validation seam (A.1), the malformed-metadata
> catalog flag (A.5), unknown-fields-ride-along (C.1), the engine-unchanged rule
> (§0), and the N=1 `document` backcompat anchor (A.2). The **bdd coverage**
> capability (`feature`/`verifies` → coverage view, Part B.2) is preserved there
> as a *future* per-type surface over a generic `ref` field. The binding
> `SPEC.md` §22.4a contract is to be amended by the successor's SLICE-0. Kept for
> historical rationale; do not build from this document.
> **Status:** discovery/spec pass — *not yet sliced into a shipped release.*
> Author session: 0083 (2026-06-06). This document is the spec pass the §22 S6
> remainder called for: it specifies **§22.4a item 1** (the per-type entry
> **frontmatter schema**) and **§22.4a item 3** (the per-type **surfaces**) for
> the `specification` and `bdd` collection types, with BDD-style acceptance
> scenarios and a delivery slicing, so a later coding session can build them
> against a written contract rather than improvising.
>
> It is the sibling of
> [`2026-06-05-three-tier-projects-collections.md`](./2026-06-05-three-tier-projects-collections.md)
> (the three-tier model, S1S6 core, shipped through v0.46.0) and refines, in
> implementable detail, what `SPEC.md` §22.4a states at the contract level.
> §22.4a **item 2** (the type-driven entry noun / terminology) shipped in the S6
> core (v0.45.0) and is out of scope here.
## 0. Why this is its own pass
`SPEC.md` §22.4a says a collection's immutable `type` selects exactly three
things: (1) the entry **frontmatter schema**, (2) the **terminology**, and (3)
the **type-specific surfaces**. The S6 core shipped (2) and merged the contract
into `SPEC.md`; it also shipped the type *plumbing*`collections.type` is
immutable, validated (`registry.VALID_TYPES = {document, specification, bdd}`),
and drives the entry noun. What it did **not** ship is any *behavior* keyed on
type beyond the noun: every type today parses the same §2 baseline frontmatter
(`backend/app/entry.py` is type-agnostic and lenient about unknown keys) and
renders the same §7 catalog with no type-specific surface.
Items 1 and 3 were deliberately deferred at v0.45.0 (CHANGELOG: "they want a
discovery/spec pass first, lacking BDD scenarios in Part C"). The three-tier
design doc's Part C scenarios are all about **roles** (C.1C.3); there are no
scenarios describing what a `specification` release-planning view *does* or what
a `bdd` coverage view *shows*. This document supplies them.
The governing constraint from §22.4a, which every proposal below honors:
> 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. … the engine itself treats every entry as
> markdown + frontmatter regardless of type.
So a per-type surface is **additive and read-mostly**: it reads the (now
type-aware) frontmatter and presents a derived view. It never forks the write
path, the PR lifecycle, or the storage model.
---
# Part A — Item 1: the per-type frontmatter schema
## A.1 Where validation lives today, and where it should land
`entry.py:parse()` reads a fixed set of §2 baseline fields and is **lenient**:
unknown keys are ignored, future fields ride along untouched (its own docstring
says so). That leniency is the seam. The per-type schema is layered as a
**validator**, not a parser rewrite:
- `entry.py` keeps parsing the union of all known fields into the `Entry`
dataclass (add the new optional fields below; absent → `None`/default, exactly
as `models`/`funder`/`unreviewed` already do). The parser stays type-agnostic.
- A new **`entry_schema.py`** module exposes `validate(entry, collection_type)
-> list[str]` returning human-readable problems (empty = valid). It is the one
place that knows which fields a type **requires**, which it **forbids**, and
the **enum/shape** of each.
- Validation is **advisory at parse, enforced at the write boundary.** The
propose/edit/PR-merge paths (§9.1, §22.4b) call `entry_schema.validate` and
surface problems the way the propose modal already surfaces field errors. A
malformed historical file still *parses* (we never hard-fail a read — a
deployment's existing corpus must keep loading), but the catalog flags it
(§A.4) and the next write must fix it.
This mirrors how visibility/initial_state are validated centrally in
`registry.py` rather than at each call site.
## A.2 `document` — unchanged (the §2 baseline)
`document` is the baseline: the §2 fields exactly as today
(`slug, title, state, id, repo, proposed_by, proposed_at, owners, arbiters,
tags`, plus the §6.6/§6.7 `models`/`funder` and §22.4c `unreviewed`/`reviewed_*`).
No new fields, no type-specific surface. The §22.13 generated default collection
is `document`, so **N=1 deployments see zero change** — the load-bearing
backcompat guarantee.
## A.3 `specification` — versioned-spec metadata
A `specification` entry is a versioned technical spec (the archetype is this
framework's own `SPEC.md`). Frontmatter **adds** (all optional at parse,
required/validated per A.1 at write):
| Field | Shape | Meaning | Required when |
|---|---|---|---|
| `spec_version` | semver string (`MAJOR.MINOR.PATCH`) | the entry's own version | `state = active` |
| `lifecycle` | enum `draft \| active \| superseded` | spec lifecycle, **orthogonal to** the §2.4 entry `state` | always (defaults `draft`) |
| `supersedes` | list of slugs (in this collection) | specs this one replaces | optional |
Notes / decisions:
- **`lifecycle` ≠ `state`.** The §2.4 `state` (super-draft/active/withdrawn) is
the *engine's* workflow position; `lifecycle` is the *spec's* editorial status.
An `active` (graduated) entry can be `lifecycle: draft` (published but not yet
ratified) or `superseded`. Keeping them orthogonal avoids overloading the
shared state machine (the §22.4a "engine unchanged" rule).
- **`supersedes` is validated as in-collection slugs** (§22.14 §2: slugs are
unique *per collection*). A `superseded` lifecycle with no inbound
`supersedes` from a newer entry is a soft warning in the surface, not a write
error (the replacement may land later).
- `spec_version` uses the same semver vocabulary as the framework `VERSION`/§20
so the release surface (A.5 / Part B) can sort and group.
## A.4 `bdd` — feature/scenario metadata
A `bdd` entry states a feature as Given/When/Then scenarios. Frontmatter
**adds**:
| Field | Shape | Meaning | Required when |
|---|---|---|---|
| `feature` | string | the feature's one-line statement (the "In order to / As a / I want" intent) | `state = active` |
| `verifies` | list of refs | the `specification` entries/sections this feature exercises | optional |
| `scenarios` | derived, **not** frontmatter | count/list parsed from the body's `Scenario:` blocks | n/a |
Notes / decisions:
- **`verifies` is a cross-collection ref.** A ref is `"<collection>/<slug>"` or
`"<collection>/<slug>#<anchor>"`. The default `<collection>` is a sibling
`specification` collection in the same project; an unqualified `<slug>` means
"a spec slug in this project's specification collection" (resolved at render).
This is the one place a `bdd` surface reaches across collections — and §22's
"no app surface joins across collections" rule (`SPEC.md` line 5016) is
**honored**: `verifies` is a *declared link rendered as a hyperlink*, not a
query that fuses two corpora. The coverage view (B.2) aggregates these links
but each entry still lives in exactly one collection.
- **Scenarios are parsed from the body, not frontmatter.** Gherkin-style
`Scenario:` / `Given`/`When`/`Then` lines in the markdown body are the source
of truth; the surface counts and lists them. This keeps the authoring
experience plain-markdown (the engine's invariant) — no structured
scenario-editor write path.
- `bdd` collections default `initial_state: active` (§22.4b) so a feature lands
active-but-`unreviewed`; the schema validator therefore requires `feature` for
active entries, which is every freshly-landed `bdd` entry.
## A.5 Schema surfacing in the existing chrome
Item-1 work is mostly invisible plumbing, but two small surfaces make it real
without waiting for Part B:
1. **Propose/edit validation** — the propose modal and edit-branch flow run
`entry_schema.validate` for the collection's type and block submit on errors
(e.g. proposing into a `specification` collection without a `lifecycle`).
2. **A "malformed frontmatter" catalog flag** — the §7 catalog marks entries
whose stored frontmatter fails its type's schema (parallel to the §22.4c
`unreviewed` filter), so a corpus migrated from `document`→… or hand-edited
is visibly fixable.
---
# Part B — Item 3: the type-specific surfaces
A surface is an **additional view** layered on the shared §7 catalog +
§8 entry view, selected on `collection.type`. It is read-derived from
frontmatter + body; it adds no write path the engine doesn't already have.
## B.1 `specification` → the release-planning surface
§22.4a: "group entries/changes into versioned releases with a changelog +
§20-style upgrade-steps per release." Concretely, a per-collection
**Releases** view at `/p/<project>/c/<collection>/releases`:
- **A release** is a named, ordered version (e.g. `0.46.0`) with: the set of
spec entries at a given `spec_version`/`lifecycle`, a changelog body, and an
optional upgrade-steps block (the §20.4 RFC-2119 convention reused verbatim).
- **Source of truth = the content repo**, per §22.2/§22.3. A release is a file
in the collection's subfolder (proposal: `releases/<version>.md`,
frontmatter `version` + `released_at` + `entries: [slug@spec_version, …]`,
body = changelog + upgrade-steps). The registry mirror caches a `releases`
table the way it caches `collections` — git is truth, the table is a cache
(§22.2 "never written except by the mirror").
- **The view** lists releases newest-first; each expands to its changelog +
upgrade-steps and the entries it cut. An Owner (scope-role, §22.6) can cut a
new release (a bot-committed file, exactly like create-collection commits a
manifest — §22 S5 pattern); contributors read.
- **Reuse, don't reinvent:** the changelog + upgrade-steps renderer is the same
markdown the framework's own `CHANGELOG.md`/§20.4 uses; the "cut a release"
write is the §22 S5 bot-commit-then-mirror pattern.
Deliberately **out of this surface** (deferred): cross-release diffing, automated
version bumping, dependency graphs between specs. The MVP is "see the releases,
their changelog, their upgrade-steps, and what each contained."
## B.2 `bdd` → the scenario/acceptance + coverage surfaces
§22.4a: "a scenario/acceptance view and a coverage view mapping features to the
spec sections they exercise." Two read-derived views:
1. **Scenario/acceptance view** (per entry, on the §8 entry page): renders the
body's parsed `Scenario:` blocks as a structured checklist — each scenario's
Given/When/Then, plus the entry's `feature` line as the header. No new
storage; pure body parse. This is the `bdd` analogue of the `document`
entry's prose view.
2. **Coverage view** (per collection, at
`/p/<project>/c/<collection>/coverage`): a matrix of **features → the spec
entries/sections they `verifies`**. Rows are this collection's `bdd` entries;
columns (or grouped rows) are the referenced `specification` entries. Cells
show "covered / declared-but-spec-missing / spec-section-with-no-feature".
The view aggregates the `verifies` links (A.4) across the collection but
renders each as a hyperlink into the spec collection — it does not fuse the
corpora (the §22 cross-collection rule, B/A.4).
Deliberately **out of this surface** (deferred): executing scenarios, CI/test
result ingestion, auto-detecting coverage from code. The MVP maps *declared*
coverage (`verifies`), surfacing gaps for humans to close.
## B.3 How a surface is selected and routed
- The collection payload already carries `type` and `entry_noun`
(`GET /api/projects/:id/collections/:cid`). The frontend's `ProjectLayout` /
collection chrome reads `type` and mounts the type's surface routes
(`releases` for `specification`; `coverage` for `bdd`) alongside the shared
catalog. `document` mounts none.
- Backend: a per-type router group (`api_releases.py`, `api_coverage.py`)
guarded by the same §22.5 read gates as the rest of the collection; the
release-cut write reuses `auth.is_collection_superuser` / the S5 bot pattern.
- The "per-type module the framework selects on `collection.type`" (§22.4a) is
realized as: backend `entry_schema.py` (item 1) + the two router groups
(item 3), and frontend a `typeModules[type]` map of `{ schema, surfaces }`.
Adding a future type = a new map entry + enum value, no rebuild (§22.4a "open
set").
---
# Part C — Behavioral scenarios (BDD)
> Tagged for the proposed slices in Part D (`@S7a` = item 1 schemas; `@S7b` =
> specification releases; `@S7c` = bdd surfaces). These are the acceptance gate
> the implementing session writes tests against, in the Part C style of the
> three-tier doc.
## C.1 Per-type frontmatter schema (`@S7a`)
```gherkin
Scenario: document collection is unchanged
Given a "document" collection
When a contributor proposes an entry with the §2 baseline frontmatter only
Then the proposal is accepted with no schema error
Scenario: specification entry requires a lifecycle
Given a "specification" collection
When a contributor proposes an entry with no `lifecycle`
Then it defaults to lifecycle "draft" and is accepted
And when an Owner graduates it to active without a `spec_version`
Then the write is blocked with "spec_version is required for an active specification"
Scenario: bdd entry requires a feature statement once active
Given a "bdd" collection whose initial_state is "active"
When a contributor proposes an entry with no `feature`
Then the write is blocked with "feature is required for a bdd entry"
Scenario: unknown future field still rides along
Given any collection
When an entry carries a frontmatter key no schema names
Then it parses unchanged and is not reported as malformed
Scenario: malformed existing entry loads but is flagged
Given a stored specification entry missing a required field
When the catalog renders
Then the entry still loads (read never hard-fails)
And the catalog marks it "malformed frontmatter"
```
## C.2 specification release-planning surface (`@S7b`)
```gherkin
Scenario: an Owner cuts a release
Given a "specification" collection with two active entries
When the collection Owner cuts release "1.0.0" with a changelog and upgrade-steps
Then a releases/1.0.0.md file is committed to the content repo
And the registry mirror caches the release
And the Releases view lists "1.0.0" newest-first with its changelog + upgrade-steps
Scenario: a contributor reads releases but cannot cut one
Given a contributor (not Owner) in the collection
Then the Releases view is read-only (no "Cut release" control)
Scenario: upgrade-steps render with the §20.4 convention
Given a release whose body uses MUST/SHOULD/MAY upgrade-steps
Then they render with the same normative-language styling as CHANGELOG.md
```
## C.3 bdd scenario + coverage surfaces (`@S7c`)
```gherkin
Scenario: an entry's scenarios render as an acceptance checklist
Given a "bdd" entry whose body has two Scenario: blocks
When the entry page renders
Then it shows the `feature` header and both scenarios' Given/When/Then
Scenario: coverage maps features to the specs they verify
Given a "bdd" entry that `verifies: ["spec/auth#sessions"]`
And a sibling "specification" collection "spec" containing entry "auth"
When the coverage view renders
Then a row links the feature to spec/auth#sessions as covered
Scenario: a declared ref to a missing spec is surfaced as a gap
Given a "bdd" entry that `verifies: ["spec/ghost"]` where no such spec exists
Then the coverage view marks that ref "declared but spec missing"
Scenario: coverage does not fuse corpora
Then each cell is a hyperlink into the spec collection
And no entry from the spec collection is listed as if it belonged to the bdd collection
```
---
# Part D — Delivery slicing
Each slice lands a usable increment + a runnable acceptance gate (`--tags @S7x`),
in the three-tier doc's slicing style. Suggested order (item 1 first — the
surfaces read its fields):
- **S7a — per-type frontmatter schema (item 1).** `entry_schema.py` +
the new optional `Entry` fields + write-boundary validation + the catalog
"malformed" flag. **Usable:** proposing into a typed collection is validated;
N=1 `document` unchanged. **Completes:** `@S7a` (C.1). *Non-breaking, additive.*
- **S7b — specification release planning (item 3a).** `releases/<v>.md` storage
+ registry mirror + `api_releases.py` + the Releases view + cut-release write.
**Usable:** a spec collection has versioned releases with changelog +
upgrade-steps. **Completes:** `@S7b` (C.2).
- **S7c — bdd scenario + coverage surfaces (item 3b).** Body scenario parser +
the per-entry acceptance view + the per-collection coverage view +
`api_coverage.py`. **Usable:** a bdd collection shows scenarios and declared
coverage. **Completes:** `@S7c` (C.3).
All three are **additive** (new optional frontmatter, new tables that are pure
caches, new read views): each is a minor, non-breaking release, and a `document`
N=1 deployment is unaffected by any of them. None touches the engine, the PR
lifecycle, or the role model — they consume the §22 three-tier + §22.6 role
work already shipped.
## D.1 Open questions for the implementing session
1. **Release identity vs. entry `spec_version`.** Should a release's `entries`
pin exact `slug@spec_version` (immutable snapshot) or just slugs (live)? This
doc proposes the pinned snapshot; confirm against a real spec-collection
workflow before building S7b.
2. **`verifies` ref grammar.** `"<collection>/<slug>#<anchor>"` is proposed;
anchor resolution into a spec entry's section needs the spec body to carry
stable anchors. May want a lightweight `## §n` anchor convention on
`specification` entries first.
3. **Whether `lifecycle` belongs in the shared state machine after all.** Kept
orthogonal here; revisit if product wants `superseded` to gate the catalog.
These are genuine product decisions a discovery/spec session (or the operator)
should settle before S7b/S7c code; S7a (schemas) is unblocked and buildable now.
@@ -0,0 +1,138 @@
---
slug: 2026-06-07-deployed-env-e2e-harness
title: "Deployed-Env E2E Harness"
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-07'
graduated_at: null
graduated_by: null
owners:
- ben.stull@wiggleverse.org
arbiters: []
tags:
- solution-design
---
# Deployed-environment E2E harness (PPE)
**Date:** 2026-06-07 · **Version:** v0.52.0 · **Status:** implemented
## Why
The §9 deployment pipeline is `localhost + E2E → PPE + E2E → prod`. The
middle stage — running the Playwright E2E suite against a *deployed*
pre-prod host (`https://rfc-ppe.wiggleverse.org`) — was unreachable
because the suite (`e2e/metadata.spec.js`, SLICE-3/4/5 of the
configurable-collection-metadata work) was bound to three scaffolds that
exist only in the local Tier-1 docker stack:
1. **A faceted `bdd` collection**, seeded into a throwaway Gitea by
`testing/seed-gitea.sh`.
2. **A granted-owner identity** (`e2e-owner@example.test`), injected
directly into SQLite by the docker-compose `backend-seed` step.
3. **Mailpit**, the SMTP sink the OTC sign-in reads the one-time code
from.
PPE has none of these: it runs against the real `git.wiggleverse.org`
(shared with prod), has no direct DB access, and has no mail sink. This
note records how each coupling is replaced so the *same* spec runs green
against both localhost and PPE.
## The three seams
### 1. Auth — a gated test-login endpoint (the framework change)
A new backend route, `POST /auth/test/login`, replaces both the Mailpit
OTC dance *and* the SQLite owner injection with one gesture: it mints an
authenticated **owner** session for a single pre-configured identity.
It is the framework's only auth bypass, so it is **fail-closed** and must
never function in production:
- **Off by default.** It returns `404` unless **both**
`E2E_TEST_AUTH_SECRET` and `E2E_TEST_AUTH_EMAIL` are set. A production
deployment sets neither, so the route is invisible and inert.
- **Secret-gated.** The caller must present `E2E_TEST_AUTH_SECRET` in the
`X-Test-Auth-Secret` header, compared in constant time. A wrong/absent
secret returns `404` (it does not advertise the route's existence).
- **Single identity.** It will only mint the one configured
`E2E_TEST_AUTH_EMAIL` (case-insensitive); any other address is `403`.
So an enabled PPE exposes exactly one throwaway owner, with the secret
as the trust boundary.
- **Loud at startup.** When enabled, the app logs a `WARNING` at boot, so
an accidental prod enablement is visible rather than silent.
On success it provision-or-links the row (reusing `otc.provision_or_link_user`),
forces it to `role='owner', permission_state='granted'` (the deployed
equivalent of the Tier-1 owner-seed), and stores the session exactly like
the OTC verify path.
**Why an endpoint rather than alternatives.** Reading the OTC code from
the VM's journald (the email adapter logs the envelope to stdout when
SMTP is unconfigured) would couple the test harness to `gcloud` SSH at
runtime — slow, brittle, and operator-cred-bound. Running Mailpit on the
VM and exposing its API publicly is more infra and its own exposure
surface. A default-off, secret-gated endpoint is the portable engineering
seam: it works for *any* deployed environment, needs no SSH, and the
secrets rule (§6.3) is honored — the secret is a Secret Manager ref
injected as VM env, never a literal.
The hard-secrets caveat: the E2E runner presents the secret by resolving
it from Secret Manager at runtime (command substitution), never echoing
it.
### 2. Content — a dedicated PPE registry + content repo
PPE shares the prod Gitea org (`wiggleverse`) and, until now, prod's
registry (`rfc-registry`) and default project (`ohm`). Seeding a faceted
test collection into that shared registry would surface it on **prod**.
So PPE gets its **own**, prod-untouching fixtures:
- `wiggleverse/rfc-registry-ppe` — PPE's project registry. Prod keeps
`rfc-registry`, so prod is never affected.
- `wiggleverse/rfc-app-ppe-content` — one project `ohm` (document) with a
default collection entry plus a faceted `bdd` named collection
(`priority` enum + `tags`) and three entries, mirroring the Tier-1
seed. The E2E path `/p/ohm/c/bdd` therefore resolves identically on
both environments.
PPE is pointed at it with `overlay set rfc-app-ppe
REGISTRY_REPO=rfc-registry-ppe`. The startup reconciler sweep loads the
content into `cached_rfcs` (incl. `meta_json` for facets) on the next
deploy — no webhook needed for the initial load. The seed is scripted in
`testing/seed-ppe.sh` (idempotent; `RESEED=1` restores entry values for a
re-run). Repo *creation* is a one-time operator gesture (the
`write:repository` Keychain token cannot create org repos; create the two
empty repos in the Gitea UI or re-scope the PAT).
### 3. Parameterization — one spec, two environments
- `e2e/playwright.config.js` already honors `BASE_URL`
(default `http://localhost:8080`); PPE sets
`BASE_URL=https://rfc-ppe.wiggleverse.org`.
- `e2e/lib/auth.js` branches on `E2E_TEST_AUTH_SECRET`: set → use
`/auth/test/login`; unset → the original Mailpit OTC path. `OWNER_EMAIL`
reads `E2E_OWNER_EMAIL` (PPE points it at `E2E_TEST_AUTH_EMAIL`) or the
Tier-1 default. The spec itself is unchanged, so the localhost Tier-1
path keeps working.
## PPE version
The harness *requires* the test-login endpoint to exist in the deployed
build, so PPE must run a framework version that contains it — **v0.52.0**,
not v0.51.1. PPE is pinned ahead of prod via its own
`ben/ohm-rfc/.rfc-app-version.ppe` (prod stays on `.rfc-app-version`),
realizing the "PPE stages newer versions first" note the
`deployment.ppe.toml` always anticipated.
## Known limitations
- **Re-runnability.** SLICE-4/5 mutate the seeded entries (commit
sidecars). A clean run needs seed-state preconditions; re-run after
`RESEED=1` + a cache refresh (next reconciler sweep or a redeploy).
Unlike Tier-1's `make e2e-fresh`, PPE has no per-run teardown.
- **smoke.spec.js** stays Tier-1-only (anonymous OTC smoke through
Mailpit); only `metadata.spec.js` runs against PPE.
+575
View File
@@ -0,0 +1,575 @@
---
slug: 2026-06-08-scoped-admin-ia
title: "Scoped Admin Information Architecture"
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-06-08'
graduated_at: null
graduated_by: null
owners:
- ben.stull@wiggleverse.org
arbiters: []
tags:
- solution-design
---
# Solution Design: Scoped Admin Information Architecture
| | |
| --- | --- |
| **Author(s)** | Ben Stull |
| **Reviewers / approvers** | Ben Stull |
| **Status** | `draft` |
| **Version** | v0.1.2 |
| **Source artifacts** | Product user stories (session OHM-0090.0) · Binds onto [`2026-06-05-three-tier-projects-collections.md`](./2026-06-05-three-tier-projects-collections.md) (§22) now merged into `SPEC.md` §22 · Resolves decision **D8** of [`2026-06-06-configurable-collection-metadata.md`](./2026-06-06-configurable-collection-metadata.md) |
**Change log**
| Date | Version | Change | By |
| --- | --- | --- | --- |
| 2026-06-08 | v0.1.0 | Initial draft from brainstorming session OHM-0090.0 | Ben Stull |
| 2026-06-08 | v0.1.1 | G-3 resolved (warn+allow); admin surfaces kept type-agnostic (G-11); §9 LLM authoring-context **config** surfaces added (consumption split to a separate spec, G-12) | Ben Stull |
| 2026-06-08 | v0.1.2 | §4.5 prompt collection discovery (content-repo webhook → `refresh_registry`, parity with create-collection API; D-DISCOVER), surfaced by the dogfood; added to S1 | Ben Stull |
---
## 1. Context
*The business lens is intentionally light: this design's solution is known up front — it is admin functionality inside the RFC app. The inputs are **product** user stories, not business outcomes. §1 frames the problem; the weight is in §3–§5.*
### 1.1 Executive summary
§22 delivered the *authority* model for the three-tier world — unified roles
`{owner, contributor}` attached at any of three scopes `{global, project,
collection}`, composing downward by a most-permissive union (`SPEC.md` §22.6,
§22.7). What it did **not** deliver is the *administration surface* for that
world. The admin **page** is still single-tier: `/admin` is mounted only at
deployment scope, and it mixes grains that belong to different tiers. Worse,
the management actions that *do* exist (create project, create collection,
assign owners) are reachable only through directory pages that the single-item
(`N=1`) landing redirects skip past — so on a typical one-project,
one-collection deployment an Owner has **no UI path** to manage or grow their
own deployment. The value at stake: an Owner at every tier can configure and
grow exactly the scope they own, from a surface that matches the tier they're
standing in — with no API-only dead-ends.
### 1.2 Background — the four defects
| # | Defect | Evidence |
| --- | --- | --- |
| DEF-1 | `/admin` is mounted only at deployment scope | `App.jsx:349` (`/admin/*``AdminWithSidebar`); no `/p/<id>/admin` or `/p/<id>/c/<cid>/admin` route exists |
| DEF-2 | Retired RFCs — a **collection-grained** concept — live in the global, site-owner-only admin, unfiltered across collections | `Admin.jsx:54` (`RetiredTab`, owner-gated); `GET /api/admin/retired-rfcs` lists `cached_rfcs WHERE state='retired'` with **no collection filter** (`api_admin.py:834`); `cached_rfcs` PK is `(collection_id, slug)` (mig 029) |
| DEF-3 | Graduation queue is collection-keyed data shown at site level; the frontend already pulls `useProjectId()` but only for links, never to scope the query | `Admin.jsx:723` (`useProjectId`); `GET /api/admin/graduation-queue` lists `cached_rfcs WHERE state='super-draft'` unfiltered (`api_admin.py:789`) |
| DEF-4 | Create-project / create-collection work but are stranded behind directories the `N=1` landing skips | Buttons live in `Directory.jsx:33/43` and `CollectionDirectory.jsx:66/75`; `DeploymentLanding` redirects past `Directory` when `projects.length===1` (`App.jsx:448`); `CollectionDirectory` redirects past itself when `cols.length===1` (`CollectionDirectory.jsx:45`) |
A fifth, related gap: **decision D8** of the configurable-collection-metadata
design deferred in-app editing of a collection's `fields:` schema (edit
`.collection.yaml` in git for v1). That is logically a Collection-Admin
capability and is folded in here.
### 1.3 Actors / roles
The §22 unified roles, named as the personas this design serves. Authority
**inherits downward** and composes as a most-permissive union (§22.7), so each
row below *is also* every row beneath it within its subtree.
| Role | Scope | Inherits |
| --- | --- | --- |
| **Global Owner** (incl. deployment `owner`/`admin`) | deployment | is a Project Owner of every project, and a Collection Owner of every collection |
| **Project Owner** | one project | is a Collection Owner of every collection in that project (incl. future ones) |
| **Collection Owner** | one collection | — |
| **Contributor** | global / project / collection | propose entries; at project/global scope may also create collections |
| **Requester** | (no role yet) | someone holding a private URL who can request access |
This inheritance is **not new** — it is exactly what `effective_role_at_scope`,
`is_project_superuser`, and `is_collection_superuser` already compute
(`auth.py:407`, `:598`, `:658`). This design only *surfaces* it; it changes no
resolution logic.
### 1.4 Non-goals (this design)
- No change to the §22.7 role-resolution algorithm.
- No per-scope **audit log** or **permission events** (left deployment-wide; see §6).
- No **post-create rename** of a project or collection slug (create-time only; §3.10, decision D-SLUG).
- No collection living in a **different repo** than its project's content repo (path within the project repo only; §3.11, decision D-STORE).
---
## 2. Product — user stories & traceability
The session's product user stories, each given an ID and traced to the surface
that satisfies it (§3) and the decision that shaped it (§5).
| # | User story | Satisfied by | Decision |
| --- | --- | --- | --- |
| US-1 | As a global owner, I want to create a project | Site admin → **Projects** panel (§3.4.1); scope switcher "+ New project" | — |
| US-2 | As a project owner, I want to create an RFC collection and assign owners | Project admin → **Collections** panel (§3.4.2) | — |
| US-3 | As a project owner, I want to configure the project slug (URL) while creating it | Create-project form (already collects `project_id`); §3.10 | D-SLUG |
| US-4 | Global owners are project owners for all projects | §1.3 inheritance — no change (§22.7) | — |
| US-5 | Project owners are collection owners for all collections in their project | §1.3 inheritance — no change (§22.7) | — |
| US-6 | As a collection owner, I want to configure the metadata for my collection | Collection admin → **Fields** panel (D8 resolution, §3.12) | D8 |
| US-7 | As a collection owner, I want to configure the collection slug (URL) | Create-collection form (already collects `collection_id`); §3.10 | D-SLUG |
| US-8 | As a collection owner, I want to configure the default starting state for new documents | Collection admin → **Settings** (`initial_state`, §3.4.3) | — |
| US-9 | As a collection owner, I want to configure the repo & path where my collection is stored | Collection admin → **Settings** (path within project repo, §3.11) | D-STORE |
| US-10 | As a collection owner, I want the path auto-created if it doesn't exist | Bot provisioning on create / settings save (§3.11) | D-STORE |
| US-11 | As a collection owner, I want to retire my entire collection so it's hidden from the project list | Collection admin → **Danger zone** (new `retired` state, §3.13) | D-RETIRE |
| US-12 | As a project owner, I want to mark my project Private so it's hidden from the everyone-list | Project admin → **Settings** (visibility `gated`, §3.8) | D-PRIVATE |
| US-13 | As a project owner, I want collections in a private project to be private by default | Create-collection default + runtime clamp (§3.9) | D-PRIVATE |
| US-14 | As a URL-holder of a private collection, I want to share it and let people request access | Request-access page on gated resources (§3.8) | D-PRIVATE |
| US-15 | As a member of a private project/collection, I want it to show in the project nav dropdown | Scope switcher includes member-visible private scopes (§3.3) | — |
---
## 3. Solution — the scoped admin IA
### 3.1 The model: one admin scope per tier
Three administration surfaces, one per tier, each owning **exactly** its grain
and nothing above or below it:
```
deployment ──▶ Site admin /admin
project ──▶ Project admin /p/<pid>/admin
collection ▶ Collection admin /p/<pid>/c/<cid>/admin
```
The guiding rule: **a surface lives at the tier whose data it mutates.** A
panel that edits collection-keyed data (retired entries, the graduation queue,
metadata fields) lives in Collection admin even if a Project or Global Owner —
who *inherit* collection authority — is the one using it.
### 3.2 Routing
| Route | Shell | Gate |
| --- | --- | --- |
| `/admin/*` | Site admin (existing `Admin.jsx`, slimmed) | `viewer.role ∈ {owner, admin}` *or* global Owner |
| `/p/<pid>/admin/*` | **new** `ProjectAdmin` (inside `ProjectLayout`) | `is_project_superuser(viewer, pid)` |
| `/p/<pid>/c/<cid>/admin/*` | **new** `CollectionAdmin` | `is_collection_superuser(viewer, cid)` |
Conventions, learned from the recent sidebar-nav fix (commit `8e207a6`): tab
rails use **absolute** `NavLink` paths with `end`, never relative links that
accumulate. Add path builders to `entryPaths.js`: `siteAdminPath()`,
`projectAdminPath(pid)`, `collectionAdminPath(pid, cid)`.
`ProjectAdmin` reads its scope from `useProject()` (the `ProjectContext`
provider, `ProjectLayout.jsx:20`). `CollectionAdmin` reads `useCollectionId()`
plus a collection fetch for the `viewer.role` flag (the pattern
`CollectionDirectory.jsx` already uses).
### 3.3 Reachability — the two always-present entry points (fixes DEF-4)
We **do not** special-case the `N=1` redirects. Instead we add two entry points
that are present regardless of how many projects/collections exist:
1. **Scope switcher** (top nav dropdown — satisfies US-15). Lists every project
the viewer can access and, under the current project, its collections —
*including private (gated) ones the viewer is a member of*, because
`/api/deployment` `projects[]` and `/api/projects/:id/collections` already
filter to caller-visible scopes and a member can read a gated scope (§22.5).
Owners additionally see a per-row **Manage** (gear → that scope's admin) and,
at the bottom, **+ New project** (`can_create_project`) / **+ New
collection** (`can_create_collection`).
2. **Gear / "Manage" affordance** on each project landing (`<Welcome/>` / the
project directory) and collection landing, shown to owners of that scope,
linking to the scoped admin.
Because the gear sits on the very page the `N=1` redirect *lands the Owner on*,
the dead-end disappears with no change to `DeploymentLanding` /
`CollectionDirectory` redirect logic. The existing directory create-buttons stay
(reachable when `N>1`); they become a second path, not the only one.
### 3.4 Per-scope surface inventory
#### 3.4.1 Site admin — `/admin` (deployment grain only)
| Panel | Status | Notes |
| --- | --- | --- |
| Users | unchanged | role / write-mute / permission_state |
| Allowlist | unchanged | private-beta OAuth gate |
| Audit log | unchanged | deployment-wide (future scoping — §6) |
| Permission events | unchanged | deployment-wide |
| **Projects** | **new** | list all projects; **create project** (US-1); mark a project **Private** (US-12) |
| ~~Graduation queue~~ | **removed** | re-homed to Collection admin (§3.6) |
| ~~Retired~~ | **removed** | re-homed to Collection admin (§3.6) |
#### 3.4.2 Project admin — `/p/<pid>/admin`
| Panel | Notes |
| --- | --- |
| **Settings** | project name; **visibility incl. Private** (US-12); private cascade default (§3.9) |
| **Collections** | list; **create collection & assign owners** (US-2); per-collection quick links to its admin |
| **Members** | project-scope grants + grant collection-scope (existing scope-aware `POST /api/projects/:pid/members`, `api_memberships.py`) |
| **Graduation (rollup)** | read-only union across the project's collections; convenience view (canonical surface is per-collection) |
#### 3.4.3 Collection admin — `/p/<pid>/c/<cid>/admin`
| Panel | Notes |
| --- | --- |
| **Settings** | name; **default starting state** `initial_state` (US-8); **storage path** within project repo (US-9/10, §3.11); visibility |
| **Fields** | in-app `fields:` schema editor — **resolves D8** (US-6, §3.12) |
| **Members** | collection-scope grants |
| **Graduation queue** | re-homed here, filtered to this collection (§3.6) |
| **Retired entries** | re-homed here, filtered to this collection; un-retire (§3.6) |
| **Danger zone** | **Retire collection** (US-11, new `retired` state, §3.13) |
### 3.5 Permission gating — every surface on the four-layer composition
Each surface gates on an existing `auth.py` primitive; **no new authorization
logic** is introduced. Backend gates are authoritative; the frontend mirrors
them from the `viewer` sub-object already embedded in API responses
(`/api/auth/me`, the collection/collections GETs).
| Surface / action | Backend gate | Frontend flag source |
| --- | --- | --- |
| Site admin | `viewer.role ∈ {owner,admin}` | `/api/auth/me``viewer.role` |
| Create project (US-1) | `can_create_project` (`auth.py:721`, global Owner only) | `/api/deployment``viewer.can_create_project` |
| Project admin | `is_project_superuser` (`auth.py:407`) | `/api/projects/:id/collections``viewer.role==='owner'` |
| Create collection (US-2) | `can_create_collection` (`auth.py:701`) | `viewer.can_create_collection` |
| Project/collection invite | `can_invite_at_project` / `can_invite_at_collection` (`auth.py:740/751`) | `viewer.can_invite` |
| Collection admin | `is_collection_superuser` (`auth.py:658`) | `/api/projects/:id/collections/:cid``viewer.role==='owner'` |
| Edit collection fields (US-6) | `is_collection_superuser` | `viewer.role==='owner'` |
| Retire collection (US-11) | `is_collection_superuser` | `viewer.role==='owner'` |
| Request access (US-14) | `can_request_join` (§22.8) | `viewer.can_request_join` |
### 3.6 Re-homing retired & graduation (fixes DEF-2, DEF-3)
Both are collection-keyed (`cached_rfcs` PK `(collection_id, slug)`). Their
canonical home becomes **Collection admin**, scoped to one collection:
- **New** `GET /api/projects/:pid/collections/:cid/graduation` → the existing
ready/blocked split (owners-set + zero-blocking-PRs), `WHERE collection_id = :cid`,
gated `is_collection_superuser`. The old `/api/admin/graduation-queue` is
retained only to back the **project rollup** (re-gated to project superuser,
filtered to the project's collections) and the panel removed from Site admin.
- **New** `GET /api/projects/:pid/collections/:cid/retired` → `WHERE state='retired'
AND collection_id=:cid`, gated `is_collection_superuser`.
- **Un-retire becomes collection-scoped**: `POST
/api/projects/:pid/collections/:cid/rfcs/:slug/unretire`, replacing the
slug-only owner-gated `POST /api/rfcs/:slug/unretire` (`api_graduation.py:548`).
This closes a real bug: the slug-only route is **collection-ambiguous** when
the same slug exists in two collections (the same class of bug noted for
`resolve_models_for_rfc`). See §4.4.
### 3.7 (reserved)
### 3.8 Private + request-access (US-12, US-14 — decision D-PRIVATE)
Today `gated` returns a bare **404** to non-members (`SPEC.md` §22.5; read gate
in `auth.py:635`). We refine this so a private resource is shareable by URL:
- **Signed-in non-member** hitting a direct URL to a `gated` project/collection
→ a **request-access page** (the resource's display *name* is revealed,
content is not), wired to the existing §22.8 request-to-join flow
(`join_requests`, mig 032) and the owner's existing cross-collection inbox.
- **Anonymous** visitor → redirect to login, *then* the request-access page.
Existence is never revealed to an anonymous caller.
- **Directories** still omit gated scopes (unchanged); existence is revealed
only to someone who already holds the URL **and** has signed in.
Backend: the gated read gate returns a distinguishable **403 + `{name,
can_request_join}`** for a signed-in non-member, vs **404** for genuinely-absent
ids and for anonymous callers. This is a **normative amendment to §22.5** (§4.1)
— the only binding change in this design.
### 3.9 Private-by-default cascade (US-13 — decision D-PRIVATE)
Marking a project Private (`gated`) affects collections two ways:
1. **New** collections default their create-form visibility to `gated`.
2. **Existing** collections need *no* data change: §22.5's keystone already
makes effective visibility the **stricter** of project and collection
("stricter wins"). A child stored `public` under a `gated` parent therefore
*reads* as gated at runtime. We document this and surface it in the
Collections panel ("effective: Private (inherited)") so it isn't surprising.
### 3.10 Slug configuration (US-3, US-7 — decision D-SLUG: create-time only)
Project (`project_id`) and collection (`collection_id`) slugs are the URL id and
are **set at creation** — already collected by `CreateProjectModal` /
`CreateCollectionModal`. This design adds **no post-create rename**: a rename
would break live URLs and re-key `cached_rfcs`, demanding redirect machinery
out of scope here. The Settings panels show the slug **read-only** with a "set
at creation" note. (Post-create rename-with-redirects is a candidate future
slice — §6.)
### 3.11 Collection storage config + auto-provision (US-9, US-10 — decision D-STORE)
Scope (D-STORE): a collection's content lives in **its project's content_repo**;
the collection owner configures the **path (subfolder)** within that repo, and
the path is **auto-created** if absent. No different-repo override.
- Today a collection's `.collection.yaml` lives at a conventional subfolder
(`<collection_id>/`); the registry maps the collection by that convention.
This design records an **explicit `path`** for the collection in the manifest
/ registry so path and slug are decoupled, defaulting to `<collection_id>/`.
- "Auto-create if it doesn't exist": creating a collection already writes
`.collection.yaml` at its path (so the path is provisioned by the create
commit). For a settings-time path that points somewhere new, the bot writes
the manifest (and a `.gitkeep` if needed, since git has no empty dirs) at the
new path. **Moving** an existing collection's files between paths is out of
scope (same rationale as slug rename) — path is settable but the *move* of
existing entries is deferred (§6); see gap G-4.
### 3.12 Metadata field-schema editor (US-6 — resolves D8)
The configurable-collection-metadata work shipped entry-metadata *value* editing
(contributor-gated) but deferred the *schema* editor (D8). This adds it:
- **New** owner-gated write path that commits the `fields:` block of
`.collection.yaml` via the bot, then triggers a registry refresh — the same
git-is-truth → mirror loop collection-create uses. **No** direct write to
`collections.config_json` (the mirror stays derived).
- Reuses `metadata_schema.parse_fields` / `validate` (already lenient,
advisory) for in-form validation; field types `enum` / `tags` / `text` (v1),
matching the value editor.
- **Destructive-edit handling** (decision G-3: *warn + allow*): removing a field
or narrowing an enum can orphan existing entry values. The editor **shows the
usage count and warns, but allows** the edit; orphaned sidecar values are
*kept but ignored* (consistent with the existing `metadata_malformed` advisory
flag), and the Owner-gated `metadata.migrate_collection` endpoint (shipped in
metadata SLICE-4) is offered to normalize. No hard block on removing an in-use
field — re-adding the field restores its values.
### 3.13 Collection retire (US-11 — decision D-RETIRE: distinct state)
A new **`retired` collection state**, mirroring per-entry retire (§13.7):
- **Hidden** from the project's collection list and the scope switcher.
- **Read-only**: write endpoints (propose, metadata edit, etc.) refuse for a
retired collection.
- **Reversible**: an Owner can un-retire from Collection admin.
- Members can still reach it by direct URL (read-only) — we hide, not block
reads (the chosen middle option; not a hard archive).
- **Storage**: a manifest field (`retired: true` in `.collection.yaml` or the
project registry), committed via bot → mirrored to a `collections.retired`
column, keeping git the source of truth (consistent with §22's
registry-is-truth stance). See gap G-2 for the manifest-vs-DB decision detail.
---
## 4. Data model & API deltas
### 4.1 SPEC (binding) amendments — docs-first (§4.1 handbook)
| Section | Change |
| --- | --- |
| §22.5 | Gated read gate: signed-in non-member on a direct URL → request-access (403 + name) instead of 404; anonymous → 404/login. (§3.8) |
| §22 (new sub-section) | The scoped admin IA: three admin scopes, routing, and the per-tier surface ownership rule. (§3.13.4) |
| §22 collection model | New `retired` collection state (§3.13); explicit collection `path` (§3.11). |
### 4.2 New / changed endpoints
| Method + path | Gate | Purpose |
| --- | --- | --- |
| `GET /api/projects/:pid/collections/:cid/graduation` | collection superuser | scoped graduation queue (§3.6) |
| `GET /api/projects/:pid/collections/:cid/retired` | collection superuser | scoped retired list (§3.6) |
| `POST /api/projects/:pid/collections/:cid/rfcs/:slug/unretire` | collection superuser | collection-scoped un-retire (replaces slug-only, §3.6/§4.4) |
| `PUT /api/projects/:pid` (project settings) | project superuser | name, visibility — git-writing (projects.yaml) |
| `PUT /api/projects/:pid/collections/:cid` (collection settings) | collection superuser | name, visibility, initial_state, path — git-writing (.collection.yaml) |
| `PUT /api/projects/:pid/collections/:cid/fields` | collection superuser | D8 schema editor (.collection.yaml `fields:`) |
| `POST /api/projects/:pid/collections/:cid/retire` / `.../unretire-collection` | collection superuser | retire / un-retire collection (§3.13) |
Note: **no project/collection *settings* write path exists today** — only
create + read (confirmed: no `PUT`/`PATCH` for project/collection metadata). The
settings backend (a git-writing `PUT` per scope) is net-new and is the largest
backend lift here (gap G-1).
### 4.3 New columns / state
- `collections.path TEXT` (default `<collection_id>`) — explicit storage path.
- `collections.retired` (state/flag) — mirrored from manifest.
- Both populated by the registry mirror; **git remains source of truth**.
### 4.4 Closing the slug-ambiguity bug
The current `POST /api/rfcs/:slug/unretire` keys by slug alone — ambiguous when
a slug exists in two collections (the same defect class as the known slug-keyed
`resolve_models_for_rfc`). Re-homing un-retire under
`/projects/:pid/collections/:cid/` resolves it. Audit other slug-only entry
routes for the same issue while in this area (gap G-5).
### 4.5 Prompt collection discovery on content-repo push (decision D-DISCOVER)
A collection is *discovered* — registered in the `collections` table — only by
`refresh_registry` (→ `_mirror_named_collections`, which scans each content repo
for `.collection.yaml` subdirs, `registry.py:305`). The **create-collection API
already calls `refresh_registry`** (`api_collections.py:176`), so collections
created in-app appear immediately. But the **content-repo Gitea webhook does
not**: on a content-repo push it calls only `refresh_meta_repo` /
`refresh_meta_branches` / `refresh_meta_pulls` (`webhooks.py:97100`), never
`refresh_registry`. So a `.collection.yaml` change made **directly in git** — a
new collection, or (in this design) a manifest edit that flips `visibility`,
`retired`, or `path` — is invisible until the next 300 s reconciler sweep
(`cache.Reconciler`, `cache.py:765/789`).
This was observed first-hand seeding this very spec's review collection (a
git-native collection took a full sweep to appear, and the visibility flip took
another). The fix: **the content-repo webhook also triggers `refresh_registry`**
when the push touches a `.collection.yaml` (or unconditionally — the refresh is
idempotent and cheap), bringing the git-native path to parity with the
create-collection API. This makes every manifest-backed admin write in this spec
(visibility, retire, path, fields) take effect promptly rather than on the sweep.
The 300 s reconciler stays as the backstop; this is the fast path, not a
replacement.
---
## 5. Decisions
| # | Decision | Choice | Date |
| --- | --- | --- | --- |
| D-PRIVATE | How "Private" maps to §22.5 | `gated` + request-access page (refine gated from 404 → request-access for signed-in non-members) | 2026-06-08 |
| D-SLUG | Slug editing scope | Create-time config only; no post-create rename | 2026-06-08 |
| D-STORE | Collection storage config | Path within the project's content_repo + auto-create; no different-repo override | 2026-06-08 |
| D-RETIRE | Collection retire model | Distinct `retired` state — hidden, read-only, reversible (mirrors §13.7) | 2026-06-08 |
| D-IA | Admin IA shape | Per-scope routes + scope-switcher + gear (Approach C) | 2026-06-08 |
| D-GRAD | Graduation/retired home | Canonical at collection scope; read-only rollup at project | 2026-06-08 |
| D-AUDIT | Audit/permission-events scope | Left deployment-wide for now (future scoping) | 2026-06-08 |
| D-DISCOVER | Prompt collection discovery | Content-repo webhook also triggers `refresh_registry` on a `.collection.yaml` change (parity with the create-collection API); 300 s reconciler stays as backstop | 2026-06-08 |
---
## 6. Out of scope / deferred
- Post-create **slug rename** (projects & collections) with redirects + cache re-key.
- **Moving** an existing collection's files to a new path (path is settable; the move is deferred).
- Collection in a **different repo** than its project content_repo.
- **Per-scope audit log / permission events** (stay deployment-wide).
- `ref` / `multi-enum` field types in the schema editor (still deferred per the metadata design).
- **Eliminating the collection `type` field.** The metadata work already removed
type-*driven schemas* (D9/D11); fully retiring `type` (today immutable and
terminology-only per §22.4a — it selects the entry noun) is a separate §22.4a
change, not an admin-IA concern. **This spec keeps every admin surface
type-agnostic** — `type` is never presented as a managed setting (see G-11).
---
## 7. Delivery plan (proposed slices)
Each slice is its own writing-plans → executing-plans coding session; plans
authored just-in-time. **S0 is docs-first** (handbook §4.1): amend the binding
SPEC before code.
| Slice | Scope | Delivers |
| --- | --- | --- |
| **S0** | SPEC amendments (§4.1): §22.5 gated→request-access; admin-IA section; `retired` collection state; collection `path` | Binding contract updated |
| **S1** | Scoped admin **routing shell + reachability** (routes, gear, scope switcher), re-home graduation/retired to collection-scoped endpoints + project rollup; slim Site admin; **content-repo webhook → `refresh_registry`** (§4.5) | DEF-1/2/3/4 navigational fix; US-15 |
| **S2** | **Settings write paths** (git-writing `PUT` for project & collection: name, visibility, initial_state) + Settings panels | US-8, US-12 |
| **S3** | **Private + request-access** (gated behavior change, request-access page) + private cascade default | US-13, US-14 |
| **S4** | **Collection storage path** config + auto-provision | US-9, US-10 |
| **S5** | **Metadata field-schema editor** (D8) | US-6 |
| **S6** | **Collection retire** state | US-11 |
| **S8** | **LLM authoring-context config surfaces** (project & collection Context panels; git-persisted block + source list; read-time composition + preview) — §9 | new (LLM-context request) |
(US-1/2/3/4/5/7 are largely satisfied by S1's surfacing of existing create
endpoints + inheritance; create-time slug is already collected.)
---
## 8. Risks, gaps & areas to dig into
*The analysis requested at spec time — what this design leans on that isn't yet
proven, and what a planning session must resolve before coding.*
| # | Gap / risk | Why it matters | Proposed resolution / where to dig |
| --- | --- | --- | --- |
| G-1 | **No settings write path exists** — only create + read. A coherent git-writing `PUT` per scope (project & collection) is net-new and is the bulk of the backend lift | S2 underpins S3S6; if the git-write+mirror loop for settings is wrong, everything downstream wobbles | Dig: design one `PUT` that reads the manifest, merges changed keys, commits via bot, refreshes registry — mirror the create flow (`api_collections.py:119`, `bot.create_collection`). Confirm partial-update semantics & concurrent-edit safety |
| G-2 | **Retire storage: manifest field vs DB flag** | Git-is-truth says manifest; but a DB-only flag is simpler and avoids a commit per retire | Recommend manifest field for consistency with §22; confirm the registry can carry a per-collection boolean and that retired collections survive a mirror rebuild |
| G-3 | **Destructive schema edits orphan values** | Removing a field / narrowing an enum can strand sidecar values | Editor warns; keep-but-ignore orphans (reuse `metadata_malformed`); offer `metadata.migrate_collection`. Decide whether to *block* removal of an in-use field or just warn |
| G-4 | **Path decoupled from slug** — registry today maps collection by conventional `<collection_id>/` subfolder | An explicit `path` must thread through manifest → registry → all path-resolving reads/writes; getting it half-done risks entries written to the wrong place | Audit every place that derives a collection's repo path; add `path` to the registry schema; default to `<collection_id>` for back-compat. Verify against `registry.py` parse/upsert |
| G-5 | **Slug-only entry routes are collection-ambiguous** | `unretire` (and possibly retire / others) key by slug alone (same class as `resolve_models_for_rfc`) | Re-home under `/projects/:pid/collections/:cid/`; grep for other `…/rfcs/:slug…` routes lacking collection scope and fix in the same slice |
| G-6 | **Inherited owners aren't membership rows** | A global/project Owner won't appear in a collection's Members list, which could read as "no owners" | Members panels manage *explicit* grants at that scope; add a read-only "inherited owners (via project/global)" note computed from `effective_role_at_scope`. Decide display, don't change resolution |
| G-7 | **Existence leak on gated request-access** | Revealing a private resource's name to any signed-in URL-holder is a deliberate tradeoff | Accept for the URL-holder (they have the link); never reveal to anonymous. Confirm no deployment needs a strict-404 mode; if so, make it a per-resource toggle (future) |
| G-8 | **Scope-switcher data shape** | Listing accessible-incl-private projects + per-project collections could be N calls | Lazy-load collections when a project row expands, reusing existing endpoints; only build a "my scopes" aggregate endpoint if the N calls prove heavy |
| G-9 | **"Assign owners" at collection create (US-2)** | Create returns the collection; owner assignment is a second `POST …/members` call | Two-step (create → manage members) is acceptable for v1; inline owner-assign at create is a nicety. Decide in S1/S2 |
| G-10 | **Graduation rollup reuses the old global endpoint** | Re-gating `/api/admin/graduation-queue` to project superuser + project filter must not regress the deployment-wide view if any caller still depends on it | Verify no remaining caller expects the unfiltered global behavior before re-gating |
| G-11 | **`type` retained but slated for elimination** | §22.4a keeps `type` immutable + terminology-only; the operator's stated direction is "all collections are documents + metadata." Admin surfaces must not present `type` as a managed setting or they'll contradict that direction | Admin IA stays type-agnostic; track full `type` elimination as its own §22.4a design (out of scope here) |
| G-12 | **LLM authoring-context provisioning** (new request) | A per-scope context block + context-source repos/refs for the in-app LLM raises composition, permission-leak, retrieval-architecture, and vendor-coupling questions (see §9) | Split: admin IA owns the *config surfaces*; a separate spec owns the *consumption/retrieval architecture* (§9) |
### Self-review against the solution-design requirements
- **Placeholders:** none — §3.7 is an intentional reserved spacer, all decisions resolved.
- **Internal consistency:** the per-tier "data it mutates" rule (§3.1) is applied consistently in the re-homing (§3.6) and the surface inventory (§3.4); gates (§3.5) all map to existing `auth.py` primitives.
- **Scope:** broad but coherently sliceable (§7); S2 (settings write path) is the load-bearing prerequisite and is called out as such (G-1).
- **Ambiguity:** the four decisions that had no clear default are pinned in §5; the remaining unknowns are *implementation* unknowns, captured as G-1..G-12 for the planning sessions rather than left implicit.
---
## 9. LLM authoring-context configuration (new)
*Added this session. Decision: the **configuration surfaces** are specced here
(they are genuinely admin IA); how the in-app LLM **consumes** the sources —
retrieval vs RAG, the permission model, token/freshness — is a **separate
design** (§9.5, gap G-12).*
### 9.1 Two distinct concepts (do not conflate)
| Concept | What it is | Scope | Storage |
| --- | --- | --- | --- |
| **Instruction block** | free-form markdown guidance — what this corpus is, how to collaborate on it | project & collection | git, **once per scope** |
| **Context-source list** | ordered list of sources the LLM may draw on: `repo`, `repo+path`, or a **collection-ref** | project & collection | git (scope config) |
Instructions (KB of guidance) and retrieval data (MB of code) have different
sizes, lifecycles, and trust levels — they are separate config, not one field.
### 9.2 Composition — store once, compose at read time (decision)
Project and collection blocks are stored **separately and once each**; the app
**composes** them when building the LLM prompt for an entry:
```
effective authoring context = project.instruction_block ∘ collection.instruction_block
effective sources = project.sources collection.sources
```
No project text is ever copied into a collection file. Updating the project
block changes every collection's effective context with **no file rewrites** —
the opposite of the rejected concatenate-into-files model, which drifts and
clobbers hand-edits. This mirrors the §22.7 downward role composition exactly.
**Storage:** each block is a scope-config value — a referenced `CONTEXT.md` at
the project root / collection path (neutral filename, not vendor-locked),
recorded in `.collection.yaml` / the project registry and mirrored to the DB. A
physical `CLAUDE.md` for real coding sessions, if wanted, is **generated from**
the composition or uses `@import` to reference the project file — never an
embedded copy.
### 9.3 Admin surfaces
- **Project admin → Context** (new panel): edit the project instruction block;
manage project-level sources. Gated `is_project_superuser`.
- **Collection admin → Context** (new panel): edit the collection block; manage
collection sources; **preview the composed result** (project ∘ collection) so
the owner sees exactly what the LLM will receive. Gated `is_collection_superuser`.
### 9.4 Context-source model — unify with `ref`
A source is `repo` (whole repo), `repo+path` (subtree), or a **collection-ref**
(another collection in the deployment). The collection-ref case is the **same
primitive as the deferred `ref` field type** (metadata design D10) — build one
cross-collection reference mechanism, not two.
### 9.5 Deferred to the consumption design (NOT here)
Explicitly out of scope for admin IA; belongs to the separate consumption spec:
- **Retrieval architecture** — tool/file-fetch vs embeddings/RAG (a whole repo
cannot be prompt-stuffed; this drives cost, freshness, infra).
- **Permission model / exfiltration guard** — context-repo reads must be scoped
to what the asking user may already see, or each source flagged explicitly
shareable; otherwise the LLM is a confused-deputy exfiltration channel. **Hard
prerequisite before consumption is enabled.**
- **Token budget, freshness, pinning** (latest vs a pinned ref).
- Tracing how the **current in-app chat** injects context today (it has a system
prompt per `CLAUDE.md`; its retrieval model is untraced).
### 9.6 Delivery
New slice **S8 — context config surfaces**: project & collection Context panels,
git-persisted blocks + source lists, read-time composition + preview. Independent
of the consumption design and ships value alone — owners can author context, and
the composed block can be surfaced/copied manually until consumption is built.
+600
View File
@@ -0,0 +1,600 @@
---
slug: multi-project-spec
title: "Multi-Project Spec (draft, superseded)"
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-05-30'
graduated_at: null
graduated_by: null
owners:
- ben.stull@wiggleverse.org
arbiters: []
tags:
- solution-design
---
# Draft spec — §22 Multi-project deployments + amendments + slicing plan
> Status: **draft for review.** Binding voice, but not yet merged into
> `SPEC.md`. When accepted: §22 below is appended after §21; the amendment
> notes in Part B are applied in place; the slicing plan in Part C seeds a
> new `docs/DEV.md` build section. Rationale and the decisions behind this
> live in [`multi-project.md`](./multi-project.md). Target release: the next
> minor (a pre-1.0 minor carrying breaking changes with upgrade steps, §20.2).
---
# Part A — New canonical section
## 22. Projects: multiple corpora per deployment
A **deployment** hosts one or more **projects**. A project is a single
corpus: one content repository (§1) holding entries under `rfcs/`, with
its own per-project slug namespace (the slug is the identity — §22.4), a
declared **type** (§22.4a), catalog, philosophy, branding,
member roster, and model universe. The deployment is the substrate the
projects share — one Gitea org, one bot, one account system, one inbox, one
running process — and the surface a visitor first lands on.
Everything §§121 describe about *a corpus* is now *a project*. 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 across projects. The numbered sections that assume a
single corpus are amended in Part B; §22 is the binding model they defer to.
> **Multi-project change (target: next minor — supersedes the original
> single-corpus model).** §1 originally said "for a deployment, this single
> repository is its content repository." A deployment now has a **registry**
> (§22.2) naming N content repositories, one per project. The single-corpus
> deployment is the **N=1 case** and continues to run after migration via a
> generated default project (§22.13); no deployment is forced to adopt more
> than one project. Where earlier sections say "the meta repo" or "the
> corpus," read "the project's content repo" and "the project's corpus."
### 22.1 The deployment ⇄ project relation
One deployment, N projects (N ≥ 1). A project belongs to exactly one
deployment and never moves between deployments. Projects within a deployment
are isolated by default (§22.5): an RFC, branch, thread, star, or watch
belongs to exactly one project, and no app surface joins across projects
except the per-account ones the deployment owns (the §15 inbox, the §6
account roster, sign-in).
### 22.2 The registry — git is still truth
Which projects exist, and their configuration, is declared in git, mirrored
into a `projects` cache table the same way content is mirrored into
`cached_rfcs` (§4). The registry is a file the bot reads — a `projects.yaml`
at the root of a dedicated **registry repo** under the deployment's Gitea
org. The framework learns the registry repo's location from a required env
var (`REGISTRY_REPO`, the multi-project successor to `META_REPO`); the repo's
*name* is the deployment's choice, not the framework's, per the
separation-of-concerns rule, and the framework fails loudly at startup if the
var is unset. Adding, reconfiguring, or archiving a project is a PR against
that file; the §4 webhook + reconciler keep the `projects` table in sync,
recording the merged `registry_sha` on each row for provenance.
The registry is a **deployment-side repo the framework reads**, in exactly
the sense `META_REPO` is today — not operator-tooling config. Where a
deployment is assembled by an external operator tool, that tool supplies the
`REGISTRY_REPO` value in the deployment's `.env` (as it supplies `META_REPO`
now) and is otherwise unaffected: project definitions live in git, edited by
PR, and the framework knows nothing about the tool that wrote the env var.
```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
type: document # document | specification | bdd — immutable (§22.4a)
content_repo: ohm-content # repo under the deployment's Gitea org (§22.3)
visibility: gated # gated | public | unlisted (§22.5)
initial_state: super-draft # super-draft | active — landing state of a new
# entry; defaults from type (§22.4b)
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
theme: { accent: "#5b5bd6" } # optional per-project token overrides (§22.9)
```
Project **definition and configuration** live in the registry (git). Project
**membership** lives in the app db (§22.6) — it churns at user speed and is
app state, not document state, exactly as `rfc_collaborators` is (§5).
`projects` rows are never written from user actions; they flow from the
registry mirror only.
### 22.3 Content repositories — one per project
Each project names one content repo under the deployment's single Gitea org
(naming convention `<project-id>-content`). 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-project; the slug is the identity
An entry's slug (§2) is unique **within its project**, not across the
deployment: `ohm` and `specs` may each have an `intro`. The slug **is** the
identity — a fully qualified reference is `(project_id, slug)`, and nothing
more. There is no type prefix and **no per-project numeric ID**: within a
project the slug alone is unambiguous, and the project context (its `/p/<id>/`
URL prefix and chrome) carries everything the old `RFC-NNNN` label used to.
This retires the per-project numbering of earlier drafts. The §13 graduation
flip still happens — it moves an entry from proposal to graduated state and
into the content repo — but it no longer allocates a number; the
`max(integer IDs)+1` allocator (§2.3, the old `api_graduation.py` path) is
removed. The displayed *noun* around a slug ("RFC", "Spec", "Feature") is a
presentation concern driven by the project'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 in the UI so external "RFC-0001"-style citations still
resolve, but never used for routing or lookup (the slug is). New entries are
never assigned one, and graduation does not write `id`. The field is read-only
provenance from here on.
### 22.4a Project type
Every project declares a `type` in the registry (§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 project 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 `project.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 — and
the three below are what is defined now. The type names and their behavior are
framework concepts (like role names), not deployment content: a deployment
picks which type each project 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 (title, status,
owners/arbiters, tags). No type-specific surfaces. The §22.13 generated
default project is a `document` project, so the N=1 case is unchanged.
- **`specification`** — a versioned technical specification (this framework's
own `SPEC.md` is the archetype: numbered normative sections, upgrade steps).
Frontmatter adds spec metadata (`version`, lifecycle `status` of
draft/active/superseded, `supersedes`). **Type-specific surface — release
planning:** group entries/changes into versioned releases, carry a
changelog + §20-style upgrade-steps per release, and surface "what is in the
next 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 — where
a deployment runs a BDD project alongside a specification project — a
coverage view mapping features to the spec sections they exercise.
> **Draft note.** The shared-engine boundary is locked; the per-type
> *schemas and surfaces* above (notably the specification release-planning
> data model and whether BDD scenarios are free-form markdown or a parsed
> Given/When/Then structure) are first proposals, to be pinned in the
> type-surface slice (Part C, M5).
### 22.4b Initial state of a new entry
A project sets the **landing state** a new entry takes when its creating
idea-PR merges (§2.4) — the `initial_state` registry field, one of the §2.4
super-draft 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: propose → super-draft → review → graduate.
- **`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. A behavior
spec is captured fact, not a proposal under deliberation, so it skips the
review-then-promote ceremony; branches, PRs, and discussion work afterward
exactly as on any `active` entry.
The default comes from the project's **type** (§22.4a) — each type module
supplies it — but `initial_state` is an independent registry knob: a
deployment may run a `bdd` project with `super-draft` if it wants a review
gate, or (less commonly) a `document` project that auto-actives. It changes
only the *landing state and whether graduation is required*; the underlying
propose→branch→PR→discuss engine is unchanged (shared-engine rule, §22.4a).
The §22.13 default project keeps `super-draft`, preserving the N=1 flow.
### 22.4c The `unreviewed` flag
An `active` entry carries an **`unreviewed`** boolean, orthogonal to its
`state`. It records whether a human gate has vetted the entry:
- An entry that reaches `active` by the normal **graduate** path (§13) is
**never** flagged — the graduate action, taken by an owner/admin, *is* the
review.
- An entry that **skips straight to `active`** via `initial_state: active`
(§22.4b) lands with `unreviewed = true`, because nothing reviewed it.
A **project owner**`project_admin`, or a deployment `owner`/`admin` (§22.7)
— clears the flag with a **mark-reviewed** action (§17), the same authority
that graduates an entry. Clearing stamps `reviewed_at`/`reviewed_by` for
provenance, paralleling `graduated_at`/`graduated_by`. The flag is a property
of the entry (frontmatter, §2 amendment), so it is git-truth and survives a
cache rebuild, exactly like `state`.
The §7 catalog gains an **unreviewed filter** so an owner can find the entries
awaiting review; it is the natural worklist for the mark-reviewed action.
`unreviewed` only applies to `active` entries — a `super-draft` is pre-review
by definition, and `withdrawn` is out of scope.
### 22.5 Project visibility
Each project carries a `visibility`, defaulting to **gated**:
- **`gated`** (default) — the project is invisible to non-members. It does
not appear in the directory (§22.10), its RFCs return 404 to non-members,
and reading or writing anything in it requires membership (§22.6). This is
the baseline because a deployment may host specs and vision docs it is not
ready to publish.
- **`public`** — any visitor may read the project's RFCs under the §6.1
anonymous-read contract; the project appears in the directory; contributing
still requires a `project_contributor` grant. This is the mode that
preserves the pre-multi-project open-by-default behavior, and the mode a
generated default project (§22.13) is seeded into.
- **`unlisted`** — readable by anyone with a direct link, but not shown in
the directory and not enumerated by `GET /api/deployment`.
Visibility is the project's; it does not relax the §11 per-branch
`read_public` controls *within* a project, which continue to apply on top.
### 22.6 Project membership and roles
Membership is a `project_members(project_id, user_id, role, granted_by,
granted_at)` table, one role per (user, project). The role is a **new middle
tier** between the §6.1 deployment roles and the §6.3 per-RFC authority:
1. **`project_viewer`** — read the project's RFCs and participate in
discussion (chat, flags) on anything readable. No propose/branch/PR. The
discuss-only counterpart of the §12 per-RFC `discussant`, at project scope.
2. **`project_contributor`** — everything a viewer can do, plus the §6.1
contributor capabilities *within this project*: propose RFCs into it,
create branches, open PRs, claim unclaimed super-drafts.
3. **`project_admin`** — everything a contributor can do, plus the §6.1
admin capabilities *within this project*: manage its membership, act on
any RFC in it (merge on behalf of arbiters, graduate, set branch
visibility, withdraw/reopen), and edit per-RFC delegated authority.
`project_admin` is the §6.3 delegated-authority idea lifted from per-RFC
to per-project: an admin scoped to one corpus, not the deployment.
Membership and role are still gated by the deployment-level
`users.permission_state='granted'` (§6): a pending account has no write
capability in any project regardless of its `project_members` rows.
### 22.7 How the three tiers compose
Authorization for an action on an RFC resolves by taking the **most
permissive** of:
- the actor's **deployment role** (§6.1) — `owner`/`admin` are superusers in
every project; a plain authenticated `contributor` has, by itself, only
anonymous-equivalent access to a project until §22.6 grants it a role
(subject to §22.5 visibility);
- the actor's **project role** in that RFC's project (§22.6);
- the actor's **per-RFC authority** in that RFC (§6.3 `owners`/`arbiters`,
§12 `rfc_collaborators`).
Concretely: deployment `owner`/`admin``project_admin` ⊇ RFC
`owners`/`arbiters`; deployment `contributor` + `project_contributor` ⊇ RFC
`rfc_collaborators(contributor)`; `project_viewer``discussant`. The §6.2
write-mute and the §22.5 visibility gate are subtractive on top of whatever
the union grants.
`users.role` (§5) now means *deployment* level only. No schema change demotes
an existing owner/admin; their powers simply read as "superuser in every
project" rather than "superuser in the corpus."
### 22.8 Discovery and joining a gated project
Because a gated project is invisible to non-members, joining is by one of:
- **Invite** — a `project_admin` (or deployment admin/owner) adds a user
directly, writing a `project_members` row and fanning a §15 notification.
This reuses the §12 per-RFC invitation machinery, re-scoped to the project.
- **Request to join** — a surface analogous to §28's contribution-requests:
a user who knows a project exists (e.g. by direct link to an `unlisted`
project, or by out-of-band referral) can request membership; a
`project_admin` accepts or declines from the inbox. The request names the
desired role (defaulting to `project_viewer`).
A `public` project needs neither: read is open, and the existing §6 / §12
contribute-grant paths cover write access.
### 22.9 Branding is resolved at runtime
`VITE_APP_NAME` is **deprecated** (§20 amendment): a single build-time name
cannot serve N projects. Deployment and project identity are served at
runtime:
- `GET /api/deployment` — the deployment `name`, `tagline`, and the list of
projects the caller can see (gated projects filtered by the caller's
membership; `unlisted` omitted).
- `GET /api/projects/:id` — that project's `name`, `tagline`, philosophy
pointer, and optional `theme` token overrides applied over the §-default
`tokens.css`.
The frontend reads these instead of `import.meta.env.VITE_APP_NAME`. Two
chrome layers result: **deployment chrome** (the directory/landing, the
project switcher, the shared inbox) and **project chrome** (the §7 catalog,
the §8 RFC view, the §14 philosophy — all per project).
### 22.10 Routing and the deployment landing
Every corpus-scoped route gains a project segment: `/p/<project>/…` carries
the §7 catalog, the §8 entry view at `/p/<project>/e/<slug>`, the §9/§10
`/p/<project>/proposals/<n>`, and the §14 `/p/<project>/philosophy`. The entry
segment is the **generic `/e/`** for every type — the displayed noun ("RFC",
"Spec", "Feature") is a type-driven label (§22.4a), not part of the path, so
routing stays a single type-agnostic path and avoids colliding with the
reserved sibling segments (`proposals`, `philosophy`, …). The root `/` is the
**deployment landing**: a directory of the projects the visitor can see (per
§22.5), plus sign-in. An anonymous or non-member visitor sees only `public`
projects there. The §8.1 breadcrumb gains a leading project segment:
`OHM / Human main` (slug, with the type-driven noun as its label).
### 22.11 Notifications span projects, one inbox
Accounts are deployment-wide, so the §15 inbox is one inbox across all the
caller's projects. `notifications` and `watches` carry `project_id` (§5
amendment) so the inbox filters by project and a user can mute an entire
project. Quiet hours, digest cadence, and email preferences stay per-account
at the deployment level (§5, §15).
### 22.12 Per-project model universe
A project's `enabled_models` (registry, §22.2) defines its operator universe,
overriding the deployment `ENABLED_MODELS` (§18) when present and falling
back to it when absent. The §6.6 per-RFC `models:` list and the §6.7 funder
universe resolve *within* the project's universe — the resolution order
becomes funder universe ∩ §6.6 list ∩ project universe, with the project
universe substituting for the deployment universe at the outermost step.
### 22.13 Migration — the default project (the N=1 case)
A deployment upgrading from a pre-multi-project version is migrated to a
single **default project** so it keeps running unchanged:
1. The migration generates a `projects` row from current config:
`META_REPO → content_repo`, `VITE_APP_NAME → name`, `visibility = public`
(preserving the deployment's current open-by-default posture),
`type = document` (every pre-multi-project corpus is a document corpus),
and the `id` a **config-derived slug**: `DEFAULT_PROJECT_ID` if set, else a
slug of the deployment name, falling back to the literal `default`. (M1's
migration 026 seeds the bootstrap id `default`; the §C-M3 step re-stamps it
to the config-derived slug before any `/p/<id>/` route is public, so the id
is meaningful — e.g. `/p/ohm/…` — and never renamed after URLs go live. The
id stays framework-generic: the framework supplies no deployment name.)
2. Every existing RFC-scoped row (§5 amendment list) is stamped with that
`project_id`.
3. Old corpus-root URLs (`/rfc/<slug>`, `/proposals/<n>`) 308-redirect to
their `/p/<default-id>/…` equivalents — the entry view to
`/p/<default-id>/e/<slug>` (§22.10) — so existing links survive.
4. The operator creates the registry repo (§22.2) declaring the default
project; until they add a second project, the deployment is functionally
identical to before, with one extra path segment.
This is the §20.4 upgrade-steps content for the release.
---
# Part B — Amendments to existing sections
Applied in place, in the established amendment-note style (cf. §1's
"Topology change (v0.31.0)").
- **§1 Repository topology.** Add the §22 amendment note (above). "This
single repository is its content repository" → "each *project* names one
content repository; the deployment's registry (§22.2) lists them." The bot
and app-owned-authorization paragraphs are unchanged and now read
org-wide.
- **§2 Meta schema / §2.3 IDs.** Slugs are unique within a project; entry
filenames are unchanged (per content repo). §2.3's `RFC-NNNN` `max+1`
allocation is **removed** — the slug is the identity (§22.4); there is no
per-project number. Entries graduated before this change keep their `id`
as a frozen, read-only legacy display label (§22.4), never used for lookup.
The entry frontmatter schema becomes type-dependent
(§22.4a): `document` keeps today's fields, `specification` and `bdd` add
their type metadata. New `active`-entry fields: `unreviewed` (bool) and the
`reviewed_at`/`reviewed_by` provenance pair (§22.4c), paralleling
`graduated_at`/`graduated_by`.
- **§2.4 State machine.** The `(no entry) ─[idea-PR merged]→` transition now
targets the project's `initial_state` (§22.4b): `super-draft` as today, or
straight to `active` when the project (e.g. a `bdd` project) lands entries
there — in which case the entry is stamped `unreviewed` (§22.4c). A new
`active ─[mark-reviewed, owner/admin]→ active` self-transition clears the
flag. The rest of the machine is unchanged.
- **§5 Data model.** Add `project_id` to: `branch_visibility`,
`branch_contribute_grants`, `stars`, `threads`, `changes`, `watches`,
`notifications`, `rfc_invitations`, `rfc_collaborators`,
`contribution_requests`, `funder_consents`, the `*_seen` cursors, `actions`,
and the §4 cache tables `cached_rfcs` (PK → `(project_id, slug)`;
also mirrors the `unreviewed` frontmatter flag, §22.4c, so the §7 catalog
filter can query it without reading every entry file),
`cached_branches`, `cached_prs`, `pr_resolution_branches`,
`proposed_use_cases`. Add the new tables `projects` (carrying the immutable
`type`, §22.4a) and `project_members` (§22.2, §22.6). `users.role` is
annotated as deployment-scope (§22.7).
- **§6.1 Roles.** Add the §22.7 composition note: deployment roles are now
one of three tiers; a plain `contributor` has no implicit access to a
project until §22.6 grants a project role (subject to §22.5).
- **§6.3 Per-RFC delegated authority.** Note that `project_admin` (§22.6) is
the same delegation idea at project scope, sitting above per-RFC authority.
- **§7 Left pane.** The catalog is per-project, under `/p/<project>/`. The
deployment directory (§22.10) is a new surface above it. The catalog gains
an **unreviewed filter** (§22.4c) — the owner's worklist of `active` entries
that landed unreviewed.
- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10). The entry is
named by its slug, not a number (§22.4); the noun shown around it ("RFC",
"Spec", "Feature") is the project type's label (§22.4a).
- **§13.3 Graduation flip.** Operates on the project's content repo. It flips
status and moves the entry, but **allocates no number** — the slug is the
identity throughout (§22.4); the old per-project `RFC-NNNN` allocation is
gone. Graduation is also **conditional on the project's `initial_state`
(§22.4b)**: a project that lands entries `active` has no super-draft phase,
so the graduate action is a no-op there and is not surfaced. For those
entries the **mark-reviewed** action (§22.4c) takes graduation's place as
the owner/admin vetting step — it clears `unreviewed` instead of flipping
state.
- **§14.1 Pre-login landing.** Splits into deployment landing (the directory,
§22.10) and per-project philosophy/deck (§14 under `/p/<project>/`).
Deployment name comes from `GET /api/deployment`, not `VITE_APP_NAME`.
- **§17 Backend surface.** RFC routes gain the `/p/<project>` /
`project_id` scoping; add `GET /api/deployment`, `GET /api/projects/:id`
(returns the project's `type`, §22.4a), and the `project_members`
management + request-to-join endpoints (§22.6, §22.8). Add a
**mark-reviewed** endpoint clearing an entry's `unreviewed` flag (§22.4c,
owner/admin), and an `unreviewed` filter param on the catalog list. Type-
specific surfaces (§22.4a) add their own routes, mounted only for projects of
the matching type — e.g. the `specification` release-planning endpoints and
the `bdd` scenario/coverage endpoints.
- **§18 Stack.** `ENABLED_MODELS` is the deployment fallback; per-project
`enabled_models` overrides it (§22.12).
- **§20 Versioning / surface.** `VITE_APP_NAME` deprecated in favor of the
registry + `GET /api/deployment` (§22.9). New required backend env var
`REGISTRY_REPO` (the §20.3 env contract); `META_REPO` becomes legacy,
consulted only by the §22.13 migration to seed the default project's
`content_repo`, then unused. The registry file shape and the
`projects`/`project_members` schema join the §20.3 versioned surface. The
release is a pre-1.0 minor with a §22.13 upgrade-steps block. Note for the
changelog: a deployment assembled by an external operator tool upgrades
through the same pinned-version path as any other — the only deploy-surface
change is swapping the `META_REPO` overlay value for `REGISTRY_REPO`; the
framework's versioned contracts (`/api/health`, `VERSION`, the pin file)
are unchanged.
---
# Part C — Slicing plan
Seven slices carry §22 and its amendments end-to-end. The ordering mirrors
DEV.md's original principle — foundations and the cache/permission spine
first, the surfaces that consume them after, hardening last. Each slice is
shippable: a deployment can stop at any slice boundary and still run (the
default project keeps the N=1 case whole throughout). The project `type`
(§22.4a) rides M3 (config) and M5 (its surfaces); M1M4 are type-agnostic
because the engine is.
**M1 — The project spine (schema + default-project migration).** *(landed)*
The `projects` and `project_members` tables; `project_id` threaded
additively onto every slug-bearing §5 table (migration 026); the §22.13
default project generated and every existing row backfilled to it; the
startup backfill that fills the default project's `content_repo` from
`META_REPO`; `REGISTRY_REPO` wired into config (consumed in M3). No UI, no
routing change, no registry mirror yet — the app runs exactly as before,
single project, with the spine underneath. Additive only: no table rebuilds
(the slug-keyed uniqueness/PK rework is deferred to the slice that activates
project #2, enumerated in migration 026's header). This is the foundation
everything after builds on.
**M2 — Project-scoped authorization + the §22.7 resolver.** *(landed)*
The three-tier composition: `project_members` roles, the most-permissive
union with deployment role and per-RFC authority, the §22.5 visibility gate as
a 404 on read and 401/403 on write. Every §17 write endpoint surveyed in
§6.1's audit re-checked under the project axis. Still single visible project;
verifiable by granting/revoking roles on the default project and flipping its
visibility (`backend/tests/test_multi_project_authz_vertical.py`). Pure
app-layer — the resolver primitives live in `app/auth.py`
(`project_visibility`, `project_member_role`, `is_project_superuser`,
`can_read_project`, `can_contribute_in_project`, `require_project_readable`,
`visible_project_ids`), composed into the existing per-RFC capability helpers
and threaded into every RFC-resolution gate (`_require_rfc*`,
`_require_super_draft`, `_require_rfc_readable`, the branch/PR/graduation deep
gates). No migration (M1 shipped the tables) and no behavior change on the
public default project.
Two operator decisions pin how the implicit grant behaves on a `public`
project: **implicit-on-public** — a granted deployment `contributor` keeps its
pre-multi-project write baseline with no `project_members` row, so the N=1 case
stays whole (no backfill); and **preserve curation** — that implicit baseline
does *not* override per-RFC owner curation (only an explicit
project_contributor/admin or a deployment owner/admin does), so the v0.16.0
per-RFC invite contract is unchanged on public. Explicit `project_members`
rows and `gated`/`unlisted` visibility are where the new tier bites. This is a
deliberate liberalization of §22.5's literal "contributing still requires a
grant" for the public case; gated/unlisted honor the grant model exactly.
**M3 — Registry mirror + routing + runtime branding.** The §4 registry
mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects`
rows beyond the default); the **re-stamp of the default project's bootstrap
`id`** (`default` → the config-derived slug, §22.13 step 1) which must land
here, before any `/p/<id>/` URL is public; the `/p/<project>/` route prefix
with the generic `/e/<slug>` entry segment (§22.10) and the 308 redirects off
the old corpus-root URLs (`/rfc/<slug>``/p/<default-id>/e/<slug>`); `GET
/api/deployment` and `GET /api/projects/:id`; the frontend cut from `VITE_APP_NAME` to runtime config,
per-project `theme` token overlay. The deployment directory at `/` and the
project switcher in deployment chrome. This slice also adds the additive
`type` and `initial_state` columns to `projects` (a small migration — M1
shipped `projects` without them), mirrors both from the registry, returns
them on `GET /api/projects/:id`, and drives the entry-noun terminology off
`type` (§22.4a). It also teaches the shared creation path to honor
`initial_state` (§22.4b) — land a new entry `active` instead of `super-draft`,
stamping `unreviewed` and skipping the graduate gate when the project says so
— plus the `unreviewed` frontmatter fields (§22.4c) mirrored into
`cached_rfcs`, the owner/admin mark-reviewed action, and the §7 catalog
unreviewed filter that queries that cached column. But no type
*surfaces* yet; beyond their label, landing state, and review flag, all three
types still look the same here. After M3 a deployment with two registry projects is
fully navigable — which makes this the slice that must also land the deferred
slug-keyed uniqueness/PK rebuilds (migration 026 header) before a second
project can collide with the first.
**M4 — Per-project corpus surfaces (the second-project acceptance pass).** The
§7 catalog, §8 entry view, §9/§10 proposal/PR flows, §13 graduation, and §14
philosophy all confirmed working under project scope with the per-project,
slug-only namespace (§22.4). This is mostly *inherited* from M1M3 — the work
is an end-to-end pass that proves a second `document` project's full lifecycle
(propose → super-draft → graduate, identified by slug *in that project*), not
new build. Naming it explicitly as the acceptance slice keeps scope that
belongs in M3 from leaking in.
**M5 — Type modules + type-specific surfaces.** The per-type layer of §22.4a:
type-specific entry-frontmatter validation (`specification`/`bdd` metadata);
the `specification` **release-planning** surface; the `bdd` scenario/coverage
surface. Type-scoped routes mounted only for matching projects (§17). The
shared engine is untouched — this slice only adds the layers on top, so a
`document` project is unaffected and the M4 acceptance still holds. (The
per-type schema/surface details are the §22.4a draft note's open work.)
**M6 — Membership lifecycle.** §22.8 invite (re-scoped §12 machinery) and
request-to-join (re-scoped §28); the inbox surfacing of join requests; the
§22.11 cross-project inbox with `project_id` filtering and project-level
mute. The admin surface for managing a project's roster.
**M7 — Hardening + operator path.** Per-project `enabled_models` resolution
(§22.12) including funder/§6.6 intersection; the registry's place in
`docs/DEPLOYMENTS.md` and the flotilla operator tooling; end-to-end tests
spanning two projects with disjoint membership; the §20.4 changelog +
upgrade-steps block; the SPEC merge (Part A appended, Part B applied).
## Open items folded into the slices
- **Registry repo vs. file-in-existing-repo** — *resolved:* a dedicated
registry repo the framework reads via the `REGISTRY_REPO` env var (§22.2).
Confirmed against the flotilla operator-tooling spec: the registry is
deployment-side git content (like the corpus), not operator config, so the
operator tool's only change is swapping the `META_REPO` overlay value for
`REGISTRY_REPO`. No flotilla architectural change; no new framework⇄tool
contract. With OHM becoming one project among several, the registry sits
*above* any single project's content repo, so a file inside one project's
repo is wrong — a dedicated repo is the right home.
- **Request-to-join vs. invite-only** — drafted with both (§22.8); M6
(membership lifecycle) may ship invite-only first and add request-to-join
second if scope demands.
- **Per-type schemas and surfaces** — the §22.4a draft note's open work:
the `specification` release-planning data model and whether `bdd` scenarios
are free-form markdown or a parsed Given/When/Then structure. Pinned in M5.
+382
View File
@@ -0,0 +1,382 @@
---
slug: multi-project
title: "Multi-Project (rationale, superseded)"
state: super-draft
id: null
repo: null
proposed_by: ben.stull@wiggleverse.org
proposed_at: '2026-05-30'
graduated_at: null
graduated_by: null
owners:
- ben.stull@wiggleverse.org
arbiters: []
tags:
- solution-design
---
# Design sketch — multi-project deployments
> Status: **draft / sketch.** Not binding. This precedes the SPEC edits it
> describes. Decisions captured here were made interactively; open questions
> are flagged inline. When this stabilizes it folds into `SPEC.md` (§1, §2,
> §5, §6, §7, §8, §13, §14, §17, §20) and ships as a pre-1.0 minor with
> upgrade steps.
## The reframe
Today the framework hardcodes **deployment : corpus = 1 : 1**. One deployment
is one Gitea content repo (`META_REPO`), one global slug namespace, one
`VITE_APP_NAME` baked into the build, one flat catalog. SPEC §1 says it
plainly: "this single repository is its content repository."
This change makes it **deployment : project = 1 : N**, where *today's entire
deployment becomes the N=1 case*. A **project** is what a corpus is now: a
content repo, its own slug namespace, a declared **type** (§ "Project types"
below), its own catalog, philosophy, branding, member roster, and
enabled-models universe. The **deployment** (the subdomain — e.g. Wiggleverse)
becomes a thin shell hosting a directory of projects plus a shared
account/notification layer.
OHM — a *document* project — becomes one project among several: specs
(*specification* projects), behavior suites (*BDD* projects), vision docs, …
all under one deployment.
The value of this framing: the migration stays mechanical. Everything
deployment-scoped today splits cleanly into:
- **stays deployment-scoped** — accounts, the beta/permission gate, the inbox,
the bot service account, the Gitea org;
- **becomes project-scoped** — the corpus, branding, roles, catalog, philosophy,
enabled models.
## Decisions (locked)
1. **Project registry lives in git.** A registry (a `projects.yaml` / registry
repo) declares which projects exist and their config; adding a project is a
PR. Mirrored into a `projects` cache table. Keeps the git-is-truth invariant.
2. **Projects are membership-gated by default** (private). A non-member does
not see a private project exists. `visibility` is still a per-project field
with `public` and `unlisted` escape hatches (see §3) — gated is the default,
not the only mode.
3. **Entries are identified by slug, scoped to the project.** A fully
qualified reference is `(project_id, slug)`. There is *no* type prefix and
*no* per-project numeric ID: inside a project the slug alone is
unambiguous, and the project (its URL prefix, its chrome) supplies all the
surrounding context. This **supersedes** the earlier per-project `RFC-NNNN`
allocation idea — graduation (§13) still flips an entry's status, but no
longer mints a number.
4. **Each project declares a `type`**`document`, `specification`, or `bdd`.
Type is chosen when the project is created (in the registry), is immutable,
and selects the project's entry frontmatter schema, its terminology/labels,
and any type-specific surfaces (e.g. release planning for specifications).
All types ride the *same* propose→branch→PR→graduate engine, threads,
flags, and chat; type layers on top — it does not fork the lifecycle.
## Project types
A project's `type` is a registry field, fixed at creation. It does **not**
change the engine — every type uses the same content repo, the same
propose→branch→PR→discuss→graduate lifecycle, the same threads/flags/chat. It
selects three things: the **entry frontmatter schema** the project validates
against, the **terminology** the chrome uses for an entry, and the set of
**type-specific surfaces** the project exposes on top of the shared catalog.
The engine treats every entry as markdown + frontmatter regardless of type;
type-specific behavior is a layer, implemented as a per-type module the
framework selects on `project.type`.
> These three definitions — especially the specification release-planning
> surface and the BDD scenario model — are first drafts. The schema/surface
> details below are proposals to refine, not yet locked.
- **`document`** — long-form normative prose (OHM is the archetype: a model
of principles and definitions). Frontmatter is today's entry schema
(title, status, owners/arbiters, tags). **No** type-specific surfaces; this
is the baseline, and the N=1 default project (§7) is a `document` project.
- **`specification`** — a versioned technical specification (this app's own
`SPEC.md`, with numbered normative sections and upgrade steps, is the
archetype). Frontmatter adds spec metadata (e.g. `version`, lifecycle
`status` of draft/active/superseded, `supersedes`). Type-specific surface:
**release planning** — group entries/changes into versioned releases, track
the changelog + upgrade-steps for each, and show "what's in the next
release." (This mirrors how rfc-app itself runs VERSION + CHANGELOG +
§20 upgrade steps.)
- **`bdd`** — behavior-driven feature specs: each entry describes a feature
as scenarios in Given/When/Then form 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
(where a deployment pairs a BDD project with a specification project) a
coverage view linking features back to the spec sections they exercise.
Types are an **open set** in shape — a new type is a new module plus a new
allowed `type` value; it needs no schema migration beyond the enum. Document,
specification, and BDD are the three defined now.
**Landing state (`initial_state`).** A project also sets what state a new
entry lands in when its idea-PR merges — `super-draft` (the normal
propose→review→graduate flow) or `active` (graduated on submission). The
default comes from the type: `document` and `specification` default to
`super-draft`; **`bdd` defaults to `active`** — a behavior spec is captured
fact, not a proposal under deliberation, so it skips the review-then-promote
gate. It's an independent registry knob, so a deployment can override the
default per project. This changes only the landing state and whether
graduation is required; the propose→branch→PR engine is unchanged.
**The `unreviewed` flag.** Skipping straight to `active` means nothing vetted
the entry, so it lands with an **`unreviewed`** flag set. An entry that reaches
`active` the normal way — super-draft → graduate — is never flagged, because
graduation *is* the review. A project owner clears the flag with a
**mark-reviewed** action (same authority as graduate), and the catalog has an
**unreviewed filter** so owners can find the entries still awaiting review. The
flag is an entry property (frontmatter, git-truth like `state`), orthogonal to
the `active` state and only meaningful on `active` entries.
The separation-of-concerns rule (CLAUDE.md) is satisfied: the *type names*
and their behavior are framework concepts (like role names), not
deployment-specific content. A deployment chooses *which* type each of its
projects is; it does not define new types or rename them.
## 1. Git topology — one content repo per project
One Gitea org for the deployment, **N content repos** (`ohm-content`,
`specs-content`, `vision-content`, …). The bot already operates org-wide; it
gains more repos and one registry repo. Slug uniqueness becomes naturally
per-repo = per-project (and the slug is the whole identity — see Decision 3).
Rejected alternatives: subdirectories in one repo (`projects/<id>/rfcs/…`)
churns every path / branch-name / graduation code path and can't carry
git-layer access control per project; prefixed slugs pollute the namespace.
### The registry repo
A small repo (or a top-level file in a deployment repo) the bot reads and
mirrors. Sketch shape:
```yaml
# projects.yaml
deployment:
name: Wiggleverse
tagline: ...
projects:
- id: ohm # url slug, stable, unique in deployment
name: Open Human Model
type: document # document | specification | bdd (immutable)
content_repo: ohm-content # repo under the deployment's Gitea org
visibility: gated # gated | public | unlisted
initial_state: super-draft # super-draft | active — landing state of a
# new entry; defaults from type
philosophy_repo_path: PHILOSOPHY.md
enabled_models: [claude, gemini] # optional; falls back to deployment ENABLED_MODELS
theme: { accent: "#5b5bd6" } # optional per-project token overrides
```
Project **definition/config** is in git; project **membership** is in the DB
(it churns like `rfc_collaborators` and is app-state, not document state). The
registry gets the same webhook + reconciler treatment as content repos.
> Open: does the registry get its own repo, or is it a file in an existing
> deployment/meta repo? Ties into the `ohm-rfc-app-flotilla` operator tooling,
> which already owns deploy orchestration and could own registry edits.
## 2. Data model
Introduce a **`projects`** cache table (mirrored from the registry, like
`cached_rfcs` is mirrored from content). Then thread `project_id` through
every RFC-scoped table.
Hard constraint: once slugs are unique only *within* a project, any table
keyed on `rfc_slug` alone is ambiguous, so `project_id` must ride along on all
of them:
- `cached_rfcs` — PK becomes `(project_id, slug)`; `rfc_id` unique per project
- `cached_branches`, `cached_prs`, `pr_resolution_branches`, `proposed_use_cases`
- `threads`, `changes`, `branch_visibility`, `branch_contribute_grants`
- `stars`, `watches`, `pr_seen`, `branch_chat_seen`
- `rfc_invitations`, `rfc_collaborators`, `contribution_requests`, `funder_consents`
- `notifications`, `actions`
~15-table migration, all backfillable to a single default project (see §7).
With slug-only identity (Decision 3) there is no per-project number to
allocate: `api_graduation.py`'s `RFC-NNNN` allocator is **retired**, and
graduation reduces to the status flip + content-repo move, keyed on
`(project_id, slug)`.
New tables:
```
projects(
id TEXT PRIMARY KEY, -- 'ohm'
name TEXT NOT NULL,
type TEXT NOT NULL -- document | specification | bdd (immutable)
CHECK (type IN ('document','specification','bdd')),
initial_state TEXT NOT NULL -- super-draft | active (landing state, §types)
DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active')),
content_repo TEXT NOT NULL,
visibility TEXT CHECK (visibility IN ('gated','public','unlisted')),
config_json TEXT, -- theme, tagline, enabled_models, …
registry_sha TEXT, -- provenance of the mirrored row
updated_at TEXT
)
project_members(
project_id TEXT NOT NULL REFERENCES projects(id),
user_id INTEGER NOT NULL REFERENCES users(id),
role TEXT CHECK (role IN ('project_admin','project_contributor','project_viewer')),
granted_by INTEGER REFERENCES users(id),
granted_at TEXT,
PRIMARY KEY (project_id, user_id)
)
```
## 3. Roles — three tiers
A **middle tier** slots between today's deployment roles and per-RFC authority.
| Tier | Who | Powers |
|---|---|---|
| **Deployment** (unchanged, narrowed) | `owner` / `admin` | Create/archive projects, manage all accounts, act in any project. The beta/`permission_state` gate stays here — it gates *having an account*, not project access. A plain authenticated user has an account but no implicit project powers. |
| **Project** (NEW — `project_members`) | `project_admin` / `project_contributor` / `project_viewer` | `project_admin` = today's app-admin, scoped to one project (manage its membership, settings, graduate, act on any RFC in it). `project_contributor` = propose/branch/PR/chat. `project_viewer` = read + discuss only. |
| **RFC** (unchanged) | frontmatter `owners`/`arbiters`; `rfc_collaborators` (`contributor`/`discussant`) | Same as today, now scoped within their project. |
This is the existing owner → admin → contributor delegation pattern with a
project axis added. `users.role` reverts to meaning *deployment*-level only.
**Visibility interaction:**
- **gated** (default) — invisible to non-members; must be a member to see it
exists. Read and write both require membership.
- **public** — any authenticated user can read; contributing requires a
`project_contributor` grant.
- **unlisted** — readable by direct link, not shown in the directory.
> Philosophy tension to resolve in SPEC: today the app is open-by-default
> (anonymous read, §11.1; admission gates only writing). Gated-by-default
> reverses that for the common case. The `public`/`unlisted` modes preserve the
> old behavior for projects that want it, and the deployment can choose its own
> default posture — but §11 and §14 need rewriting to make "gated" the baseline
> and anonymous read a per-project opt-in.
**Discovery for gated projects:** since a stranger sees an empty directory,
there must be a join path — invite-only (a `project_admin` adds you), or a
request-to-join surface analogous to the existing §28 contribution-request
flow. (Open — pick one.)
## 4. Branding & frontend (forced change)
The one *forced* change. `VITE_APP_NAME` is baked at build time; you cannot
bake N project names into one bundle. Branding moves to **runtime config
served by the backend**:
- `GET /api/deployment` → deployment name/tagline + the list of projects the
caller can see (gated ones filtered by membership).
- `GET /api/projects/:id` → that project's name, tagline, philosophy, theme
tokens.
- Frontend reads these instead of `import.meta.env.VITE_APP_NAME`
(`App.jsx:208`, `Landing.jsx:16`, `BetaPending.jsx:17`).
`VITE_APP_NAME` is deprecated → deployment name comes from the registry. This
is a documented config change with upgrade steps per CLAUDE.md.
Two chrome layers result:
- **Deployment chrome** — the Wiggleverse header, the project directory /
landing, a project switcher.
- **Project chrome** — the current header / catalog / philosophy, now per
project; theme tokens (`tokens.css`) overridable per project at runtime.
## 5. Routing & UX
- Entry routes gain a project prefix with a **generic `/e/` segment**:
`/p/<project>/e/<slug>`, `/p/<project>/proposals/<n>`,
`/p/<project>/philosophy`, etc. The segment is the same for every type; the
noun shown around the slug is a type-driven label, not part of the path.
- Root `/` becomes the **deployment landing = project directory** (the
Wiggleverse home). For an anonymous or non-member visitor under gated
default, that's only public/unlisted-by-link projects.
- Breadcrumb gains a segment: `Wiggleverse / OHM / Human main` (the entry
is named by its slug; the entry-noun the chrome uses around it is
type-driven — "RFC", "Spec", "Feature").
- The left-pane catalog (§7) becomes per-project; a project switcher lives in
deployment chrome.
## 6. Cross-cutting — notifications, inbox, watches
Accounts are deployment-wide, so there is **one inbox spanning projects**
(§15). `notifications` and `watches` carry `project_id` so the inbox is
filterable and a user can mute an entire project. Quiet hours / digest prefs
stay per-account (deployment level).
## 7. Backward compatibility & migration
The N=1 path keeps existing single-project deployments working:
1. Migration creates one **default project** from current config (`META_REPO`
`content_repo`, `VITE_APP_NAME``name`, visibility seeded to match the
deployment's current open posture, likely `public`). Its `id` is a
config-derived slug (`DEFAULT_PROJECT_ID`, else slug of the deployment
name, else `default`); M1's `default` bootstrap id is re-stamped to it in
M3 before any `/p/` URL is public.
2. Every existing row's `project_id` is stamped to that default project.
3. An optional default-project redirect keeps old `/rfc/<slug>` URLs alive
(308 → `/p/<default-id>/e/<slug>`).
This is the SPEC §20 upgrade-steps block.
## 8. SPEC & versioning impact
Touches §1, §2, §5, §6, §7, §8, §13, §14, §17, §20. Pre-1.0 minor with
breaking changes spelled out (schema migration, `VITE_APP_NAME` deprecation,
URL change, gated-default philosophy shift). Single-process SQLite stays fine —
`project_id` is just a column; no DB-per-project, no Postgres forced.
## Operator-tooling integration (flotilla)
Checked against the `ohm-rfc-app-flotilla` spec (the OHM deployment's operator
control panel). It assembles a deployment from `{rfc-app@pin} + {non-secret
overlay} + {secret pulls} + {corpus}`, **does not host the corpus** ("the
corpus lives in a deployment-side repo"), and **depends on the framework only
through versioned contracts** (`/api/health`, `VERSION`, CHANGELOG
upgrade-steps, the `.rfc-app-version` pin). The framework knows nothing about
flotilla.
This confirms the registry decision and fixes how it integrates:
- The registry is **deployment-side git content the framework reads** — same
category as the corpus — *not* a flotilla-owned config blob. Putting
project definitions in operator tooling would re-bake deployment specifics
into the assembly layer and add a new framework⇄tool contract.
- The framework reads the registry via a `REGISTRY_REPO` env var, the
multi-project successor to `META_REPO`. Flotilla's overlay simply swaps one
ref; its four versioned contracts are untouched.
- Multi-project therefore ships to OHM as an **ordinary pinned-version
upgrade**: bump the pin, run the §22.13 default-project migration, change
`META_REPO``REGISTRY_REPO` in the overlay, deploy, verify via
`/api/health`. No flotilla architectural change.
## Open questions
- Gated discovery: invite-only vs. request-to-join surface? (drafted with
both; M5 can ship invite-only first.)
- Does the deployment landing itself need branding config, or is it derived
entirely from the registry `deployment:` block?
- Per-project `ENABLED_MODELS` resolution vs. deployment universe (§18, §6.6,
§6.7 funder) — confirm fallback order.
- Slicing plan for the build (mirrors DEV.md's original slice approach).
- **Type surfaces — depth of each.** What concretely is in the
`specification` *release-planning* surface (its own tables? a release =
a tag + a changelog entry + a set of graduated entries?), and does the
`bdd` scenario model stay free-form markdown or get a structured
Given/When/Then schema the app parses? Drafted shallow; pin before the
type-surface slice.
- **Entry-noun in URLs/labels** — *resolved 2026-06-02:* generic route
segment `/p/<project>/e/<slug>` for every type; the displayed noun
("RFC"/"Spec"/"Feature") is a type-driven label, not part of the path
(§22.10, §22.4a).
- **Existing graduated numbers** — *resolved 2026-06-02:* pre-change graduated
entries keep their `RFC-NNNN` `id` in frontmatter as a frozen, read-only
legacy display label (preserves citations); never used for lookup, never
assigned to new entries (§22.4).
- **Default project `id`** — *resolved 2026-06-02:* a config-derived slug
(`DEFAULT_PROJECT_ID`, else slug of the deployment name, else `default`);
M1's `default` bootstrap id is re-stamped in M3 before any `/p/` URL is
public, so it's meaningful (e.g. `/p/ohm/`) and never renamed live (§22.13).