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>
This commit is contained in:
@@ -0,0 +1,255 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user