Files
Ben Stull ad2ece18fa docs(design): resolve the three M3-blocking decisions (§22 draft)
- Default project id: config-derived slug (DEFAULT_PROJECT_ID > slug of
  deployment name > 'default'); M1's 'default' bootstrap is re-stamped in M3
  before any /p/ URL is public, so it's meaningful (/p/ohm/) and never renamed
  live. §22.13.
- Entry-noun in URLs: generic /p/<project>/e/<slug> for every type; the noun
  (RFC/Spec/Feature) is a type-driven UI label, not in the path. §22.10.
- Existing RFC-NNNN: kept as a frozen, read-only legacy display label in
  frontmatter id (preserves citations); never used for lookup, never assigned
  to new entries. §22.4, §2.3 amended.

Threads through §22.4/22.10/22.13, the §2.3 amendment, and the M3 slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:03:15 -07:00

366 lines
19 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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).