Files
rfc-app/docs/design/multi-project-spec.md
T
Ben Stull 6f901e3e2c feat(projects): M1 — the multi-project schema spine (§22)
Lays the additive foundation for hosting N projects per deployment, with
today's single corpus as the N=1 case. No behavior change: the app runs
exactly as before, single project, with the spine underneath.

- migration 025: `projects` + `project_members` tables; seed the `default`
  project (visibility=public, preserving open-by-default); thread
  `project_id NOT NULL DEFAULT 'default'` onto all 19 slug-bearing tables,
  backfilling existing rows. Additive — no table rebuilds; the slug-keyed
  uniqueness/PK rework is enumerated in the migration header and deferred to
  the slice that activates project #2.
- app/projects.py: §22.13 startup backfill of the default project's
  content_repo from META_REPO (idempotent — never clobbers a value the
  future registry mirror sets).
- config: REGISTRY_REPO wired (optional through M1; consumed by the M3
  mirror), documented in .env.example as META_REPO's successor.
- tests: 6 vertical assertions on the spine (seed, backfill, column shape,
  role CHECK, idempotency, config). Full suite 381 passed.
- docs: align Part C M1/M3 boundaries with the landed code (registry mirror
  + redirect move to M3).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 18:14:53 -07:00

20 KiB
Raw Blame History

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. 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 §§121 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 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/adminproject_admin ⊇ RFC owners/arbiters; deployment contributor + project_contributor ⊇ RFC rfc_collaborators(contributor); project_viewerdiscussant. 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 + default-project migration). (landed) The projects and project_members tables; project_id threaded additively onto every slug-bearing §5 table (migration 025); the §22.13 default project generated and every existing row backfilled to it; the startup backfill that fills the default project's content_repo from META_REPO; REGISTRY_REPO wired into config (consumed in M3). No UI, no routing change, no registry mirror yet — the app runs exactly as before, single project, with the spine underneath. Additive only: no table rebuilds (the slug-keyed uniqueness/PK rework is deferred to the slice that activates project #2, enumerated in migration 025's header). This is the foundation everything after builds on.

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 — Registry mirror + routing + runtime branding. The §4 registry mirror (webhook + reconciler over the REGISTRY_REPO, populating projects rows beyond default); the /p/<project>/ route prefix and the 308 redirects off the old corpus-root URLs; 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 — which makes this the slice that must also land the deferred slug-keyed uniqueness/PK rebuilds (migration 025 header) before a second project can collide with the first.

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 M1M3; 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-reporesolved: 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.