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>
12 KiB
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/RFC-NNNN namespace, 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 becomes one project among several (specs, vision docs, …) 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)
- 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 aprojectscache table. Keeps the git-is-truth invariant. - Projects are membership-gated by default (private). A non-member does
not see a private project exists.
visibilityis still a per-project field withpublicandunlistedescape hatches (see §3) — gated is the default, not the only mode. - RFC numbering is per-project. Each project has its own
RFC-0001,RFC-0002… computed asmax(existing IDs in this project) + 1.
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 and RFC-NNNN
sequences become naturally per-repo = per-project.
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:
# projects.yaml
deployment:
name: Wiggleverse
tagline: ...
projects:
- id: ohm # url slug, stable, unique in deployment
name: Open Human Model
content_repo: ohm-content # repo under the deployment's Gitea org
visibility: gated # gated | public | unlisted
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-flotillaoperator 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_idunique per projectcached_branches,cached_prs,pr_resolution_branches,proposed_use_casesthreads,changes,branch_visibility,branch_contribute_grantsstars,watches,pr_seen,branch_chat_seenrfc_invitations,rfc_collaborators,contribution_requests,funder_consentsnotifications,actions
~15-table migration, all backfillable to a single default project (see §7).
api_graduation.py's RFC-NNNN allocator scopes its max() query by
project_id.
New tables:
projects(
id TEXT PRIMARY KEY, -- 'ohm'
name TEXT NOT NULL,
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_contributorgrant. - 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/unlistedmodes 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
- RFC routes gain a project prefix:
/p/<project>/rfc/<slug>,/p/<project>/proposals/<n>,/p/<project>/philosophy, etc. - 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 / RFC-0042 · Human › main. - 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:
- 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, likelypublic). - Every existing row's
project_idis stamped to that default project. - An optional default-project redirect keeps old
/rfc/<slug>URLs alive (308 →/p/<default>/rfc/<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_REPOenv var, the multi-project successor toMETA_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_REPOin 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_MODELSresolution vs. deployment universe (§18, §6.6, §6.7 funder) — confirm fallback order. - Slicing plan for the build (mirrors DEV.md's original slice approach).