Files
rfc-app/docs/design/multi-project.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

256 lines
12 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.
# 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)
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. **RFC numbering is per-project.** Each project has its own `RFC-0001`,
`RFC-0002`… computed as `max(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:
```yaml
# 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-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).
`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_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
- 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:
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`).
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>/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_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).