Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 58be88d085 | |||
| 5d0cadda3b | |||
| cc8d371833 | |||
| 85edd17ac4 | |||
| f65a2234f7 | |||
| 056acd360f | |||
| 8e56ed72d8 | |||
| ef3a1ad331 | |||
| 8c7c6ff9c1 | |||
| 6883f8b6c8 | |||
| e24e82c6ad | |||
| 214f69e08e | |||
| 656d5627f2 | |||
| d21470ccc4 | |||
| 04afcd9bd6 | |||
| a0a136fe3d | |||
| 96a11ec96c | |||
| 039555a85e | |||
| 619adde407 |
@@ -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,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,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
@@ -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,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
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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,7 +1,7 @@
|
||||
---
|
||||
slug: nonextraction
|
||||
title: Non-Extraction
|
||||
state: super-draft
|
||||
state: withdrawn
|
||||
id: null
|
||||
repo: null
|
||||
proposed_by: ben.stull@wiggleverse.org
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
|
||||
|
||||
@@ -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,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
|
||||
|
||||
@@ -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 A–D) is unaffected. Target
|
||||
> release: a further pre-1.0 minor with breaking changes + upgrade steps (§20.2).
|
||||
|
||||
---
|
||||
|
||||
## 0. Why this revision
|
||||
|
||||
The original §22 (`multi-project-spec.md`) gave a deployment **N projects**,
|
||||
where each project *was* a single typed corpus: one content repo, one `type`,
|
||||
one slug namespace, one member roster. That conflates two responsibilities —
|
||||
**organizational grouping** and **a typed body of entries** — into one noun.
|
||||
|
||||
This revision splits them. A **project** becomes a pure grouping tier (settings
|
||||
+ one content repo) that holds **any number of RFC collections**; an **RFC
|
||||
collection** is the typed corpus the original §22 called a "project." Everything
|
||||
the original §22 said about a corpus (type, slug namespace, catalog, philosophy,
|
||||
landing state, review flag, membership) moves down one level to the collection;
|
||||
the deployment level is unchanged.
|
||||
|
||||
⚠️ The two-tier model is **already shipped** (v0.39.0): migration 028 rebuilt
|
||||
the slug PK to `(project_id, slug)`, and `/p/<project>/e/<slug>` URLs are live
|
||||
(v0.35.0). So inserting the third tier evolves a shipped system — see 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):** M1–M2, 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.3–C3.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.1–C3.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 (S7a–S7c) 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.1–C1.8 (`@S3`) |
|
||||
| **S4** | invitation UI + role-aware empty states | C2.1–C2.7, C3.3–C3.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.1–1.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 P0–P3, 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 — §§3–7 — 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 frontmatter→sidecar 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 Per–Product-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).
|
||||
```
|
||||
@@ -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, S1–S6 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.1–C.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 (§§9–13), 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.
|
||||
@@ -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.1–3.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:97–100`), 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 S3–S6; 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.
|
||||
@@ -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 §§1–21 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 (§§9–13), 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); M1–M4 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 M1–M3 — 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.
|
||||
@@ -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).
|
||||
Reference in New Issue
Block a user