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>
20 KiB
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 newdocs/DEV.mdbuild section. Rationale and the decisions behind this live inmulti-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 §§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.
# 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 aproject_contributorgrant. 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 byGET /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:
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-RFCdiscussant, at project scope.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.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_adminis 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/adminare superusers in every project; a plain authenticatedcontributorhas, 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, §12rfc_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 aproject_membersrow 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
unlistedproject, or by out-of-band referral) can request membership; aproject_adminaccepts or declines from the inbox. The request names the desired role (defaulting toproject_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 deploymentname,tagline, and the list of projects the caller can see (gated projects filtered by the caller's membership;unlistedomitted).GET /api/projects/:id— that project'sname,tagline, philosophy pointer, and optionalthemetoken overrides applied over the §-defaulttokens.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:
- The migration generates a
projectsrow from current config:META_REPO → content_repo,VITE_APP_NAME → name,visibility = public(preserving the deployment's current open-by-default posture),ida slug derived from the deployment. - Every existing RFC-scoped row (§5 amendment list) is stamped with that
project_id. - Old corpus-root URLs (
/rfc/<slug>,/proposals/<n>) 308-redirect to their/p/<default>/…equivalents, so existing links survive. - 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+1is scoped to the project (§22.4). - §5 Data model. Add
project_idto:branch_visibility,branch_contribute_grants,stars,threads,changes,watches,notifications,rfc_invitations,rfc_collaborators,contribution_requests,funder_consents, the*_seencursors,actions, and the §4 cache tablescached_rfcs(PK →(project_id, slug),rfc_idunique per project),cached_branches,cached_prs,pr_resolution_branches,proposed_use_cases. Add the new tablesprojectsandproject_members(§22.2, §22.6).users.roleis 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
contributorhas 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-NNNNis 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 fromGET /api/deployment, notVITE_APP_NAME. - §17 Backend surface. RFC routes gain the
/p/<project>/project_idscoping; addGET /api/deployment,GET /api/projects/:id, and theproject_membersmanagement + request-to-join endpoints (§22.6, §22.8). - §18 Stack.
ENABLED_MODELSis the deployment fallback; per-projectenabled_modelsoverrides it (§22.12). - §20 Versioning / surface.
VITE_APP_NAMEdeprecated in favor of the registry +GET /api/deployment(§22.9). New required backend env varREGISTRY_REPO(the §20.3 env contract);META_REPObecomes legacy, consulted only by the §22.13 migration to seed the default project'scontent_repo, then unused. The registry file shape and theprojects/project_membersschema 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 theMETA_REPOoverlay value forREGISTRY_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_idthreaded through every §5 table per the Part B list; the §22.13 migration that generates the default project, backfillsproject_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 M1–M3; 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_REPOenv 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 theMETA_REPOoverlay value forREGISTRY_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.