7aba89655d
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>
372 lines
20 KiB
Markdown
372 lines
20 KiB
Markdown
# 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 §§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.
|
||
|
||
```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 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_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.
|