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

12 KiB
Raw Blame History

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:

# 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_REPOcontent_repo, VITE_APP_NAMEname, 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_REPOREGISTRY_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).