Files
rfc-app/docs/design/multi-project-spec.md
T
Ben Stull 7aba89655d docs(design): sketch multi-project deployments + draft §22 spec & slicing plan
Design for hosting N projects (corpora) per deployment, with today's
single-corpus deployment as the N=1 case. Captures the locked decisions
(git registry, gated-by-default visibility, per-project RFC numbering),
the three-tier role model (deployment / project / per-RFC), and the
forced runtime-branding shift off VITE_APP_NAME.

- multi-project.md: rationale, decisions, operator (flotilla) integration.
- multi-project-spec.md: draft binding §22, in-place amendments to
  §§1,2,5,6,7,8,13,14,17,18,20, and a six-slice (M1-M6) build plan.

Registry lives in a deployment-side repo the framework reads via a new
REGISTRY_REPO env var (META_REPO's multi-project successor); confirmed
against the ohm-rfc-app-flotilla spec — no operator-tooling change needed.

Not yet merged into SPEC.md or versioned; folds in when M1 lands.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:05:13 -07:00

372 lines
20 KiB
Markdown
Raw 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.
# 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 RFC entries under `rfcs/`, with
its own slug and `RFC-NNNN` namespaces, catalog, philosophy, branding,
member roster, and model universe. The deployment is the substrate the
projects share — one Gitea org, one bot, one account system, one inbox, one
running process — and the surface a visitor first lands on.
Everything §§121 describe about *a corpus* is now *a project*. Everything
they describe about *a deployment* that is not corpus-specific — accounts,
the §6 admission gate, the §15 inbox, the §1 bot — stays at the deployment
level and is shared across projects. The numbered sections that assume a
single corpus are amended in Part B; §22 is the binding model they defer to.
> **Multi-project change (target: next minor — supersedes the original
> single-corpus model).** §1 originally said "for a deployment, this single
> repository is its content repository." A deployment now has a **registry**
> (§22.2) naming N content repositories, one per project. The single-corpus
> deployment is the **N=1 case** and continues to run after migration via a
> generated default project (§22.13); no deployment is forced to adopt more
> than one project. Where earlier sections say "the meta repo" or "the
> corpus," read "the project's content repo" and "the project's corpus."
### 22.1 The deployment ⇄ project relation
One deployment, N projects (N ≥ 1). A project belongs to exactly one
deployment and never moves between deployments. Projects within a deployment
are isolated by default (§22.5): an RFC, branch, thread, star, or watch
belongs to exactly one project, and no app surface joins across projects
except the per-account ones the deployment owns (the §15 inbox, the §6
account roster, sign-in).
### 22.2 The registry — git is still truth
Which projects exist, and their configuration, is declared in git, mirrored
into a `projects` cache table the same way content is mirrored into
`cached_rfcs` (§4). The registry is a file the bot reads — a `projects.yaml`
at the root of a dedicated **registry repo** under the deployment's Gitea
org. The framework learns the registry repo's location from a required env
var (`REGISTRY_REPO`, the multi-project successor to `META_REPO`); the repo's
*name* is the deployment's choice, not the framework's, per the
separation-of-concerns rule, and the framework fails loudly at startup if the
var is unset. Adding, reconfiguring, or archiving a project is a PR against
that file; the §4 webhook + reconciler keep the `projects` table in sync,
recording the merged `registry_sha` on each row for provenance.
The registry is a **deployment-side repo the framework reads**, in exactly
the sense `META_REPO` is today — not operator-tooling config. Where a
deployment is assembled by an external operator tool, that tool supplies the
`REGISTRY_REPO` value in the deployment's `.env` (as it supplies `META_REPO`
now) and is otherwise unaffected: project definitions live in git, edited by
PR, and the framework knows nothing about the tool that wrote the env var.
```yaml
# projects.yaml (registry repo root)
deployment:
name: Wiggleverse # deployment display name (replaces VITE_APP_NAME)
tagline: ... # deployment landing deck (§22.10)
projects:
- id: ohm # url-stable slug, unique within the deployment
name: Open Human Model
content_repo: ohm-content # repo under the deployment's Gitea org (§22.3)
visibility: gated # gated | public | unlisted (§22.5)
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 Slug and ID namespaces are per-project
An RFC's slug (§2) is unique **within its project**, not across the
deployment: `ohm` and `specs` may each have an `intro`. `RFC-NNNN` integer
IDs are assigned per project at graduation as `max(existing integer IDs in
this project) + 1` (§13, §2.3 amended) — each project numbers from `RFC-0001`
independently. A fully qualified RFC reference is therefore
`(project_id, slug)`, and after graduation `(project_id, RFC-NNNN)`.
### 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 `/p/<project>/rfc/<slug>`, the §9/§10
`/p/<project>/proposals/<n>`, and the §14 `/p/<project>/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 / RFC-0042 · Human main`.
### 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), `id` a
slug derived from the deployment.
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>/…` equivalents, 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 `max+1` is scoped to the
project (§22.4).
- **§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)`, `rfc_id`
unique per project), `cached_branches`, `cached_prs`,
`pr_resolution_branches`, `proposed_use_cases`. Add the new tables
`projects` 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.
- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10).
- **§13.3 Graduation flip.** Operates on the project's content repo;
`RFC-NNNN` is allocated per project (§22.4).
- **§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`,
and the `project_members` management + request-to-join endpoints (§22.6,
§22.8).
- **§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
Six 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).
**M1 — The project spine (schema + registry + default-project migration).**
The `projects` and `project_members` tables; the §4 registry mirror (webhook
+ reconciler over the registry repo); `project_id` threaded through every §5
table per the Part B list; the §22.13 migration that generates the default
project, backfills `project_id`, and stands up the redirect. No UI yet — the
app runs exactly as before, single project, with the spine underneath. This
is the slice that must land atomically; everything after is additive.
**M2 — Project-scoped authorization + the §22.7 resolver.** 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; this slice
is verifiable by granting/revoking roles on the default project and asserting
the gates.
**M3 — Routing + runtime branding.** The `/p/<project>/` route prefix, the
308 redirects, `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. After M3 a deployment with two registry projects is fully
navigable.
**M4 — Per-project corpus surfaces.** The §7 catalog, §8 RFC view, §9/§10
proposal/PR flows, §13 graduation, and §14 philosophy all confirmed working
under project scope with per-project slug/ID namespaces (§22.4). Mostly
inherited from M1M3; this slice is the end-to-end pass that proves a second
project's full lifecycle (propose → super-draft → graduate → `RFC-0001` *in
that project*).
**M5 — 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.
**M6 — 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); M5 may
ship invite-only first and add request-to-join second if scope demands.