Files
rfc-app/docs/design/multi-project.md
T
Ben Stull ad2ece18fa docs(design): resolve the three M3-blocking decisions (§22 draft)
- Default project id: config-derived slug (DEFAULT_PROJECT_ID > slug of
  deployment name > 'default'); M1's 'default' bootstrap is re-stamped in M3
  before any /p/ URL is public, so it's meaningful (/p/ohm/) and never renamed
  live. §22.13.
- Entry-noun in URLs: generic /p/<project>/e/<slug> for every type; the noun
  (RFC/Spec/Feature) is a type-driven UI label, not in the path. §22.10.
- Existing RFC-NNNN: kept as a frozen, read-only legacy display label in
  frontmatter id (preserves citations); never used for lookup, never assigned
  to new entries. §22.4, §2.3 amended.

Threads through §22.4/22.10/22.13, the §2.3 amendment, and the M3 slice.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:03:15 -07:00

19 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 namespace, a declared type (§ "Project types" below), 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 — a document project — becomes one project among several: specs (specification projects), behavior suites (BDD projects), vision docs, … all 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. Entries are identified by slug, scoped to the project. A fully qualified reference is (project_id, slug). There is no type prefix and no per-project numeric ID: inside a project the slug alone is unambiguous, and the project (its URL prefix, its chrome) supplies all the surrounding context. This supersedes the earlier per-project RFC-NNNN allocation idea — graduation (§13) still flips an entry's status, but no longer mints a number.
  4. Each project declares a typedocument, specification, or bdd. Type is chosen when the project is created (in the registry), is immutable, and selects the project's entry frontmatter schema, its terminology/labels, and any type-specific surfaces (e.g. release planning for specifications). All types ride the same propose→branch→PR→graduate engine, threads, flags, and chat; type layers on top — it does not fork the lifecycle.

Project types

A project's type is a registry field, fixed at creation. It does not change the engine — every type uses the same content repo, the same propose→branch→PR→discuss→graduate lifecycle, the same threads/flags/chat. It selects three things: the entry frontmatter schema the project validates against, the terminology the chrome uses for an entry, and the set of type-specific surfaces the project exposes on top of the shared catalog. The engine treats every entry as markdown + frontmatter regardless of type; type-specific behavior is a layer, implemented as a per-type module the framework selects on project.type.

These three definitions — especially the specification release-planning surface and the BDD scenario model — are first drafts. The schema/surface details below are proposals to refine, not yet locked.

  • document — long-form normative prose (OHM is the archetype: a model of principles and definitions). Frontmatter is today's entry schema (title, status, owners/arbiters, tags). No type-specific surfaces; this is the baseline, and the N=1 default project (§7) is a document project.
  • specification — a versioned technical specification (this app's own SPEC.md, with numbered normative sections and upgrade steps, is the archetype). Frontmatter adds spec metadata (e.g. version, lifecycle status of draft/active/superseded, supersedes). Type-specific surface: release planning — group entries/changes into versioned releases, track the changelog + upgrade-steps for each, and show "what's in the next release." (This mirrors how rfc-app itself runs VERSION + CHANGELOG + §20 upgrade steps.)
  • bdd — behavior-driven feature specs: each entry describes a feature as scenarios in Given/When/Then form with acceptance criteria. Frontmatter adds feature metadata and an optional link to the specification entries a feature verifies. Type-specific surface: a scenario/acceptance view, and (where a deployment pairs a BDD project with a specification project) a coverage view linking features back to the spec sections they exercise.

Types are an open set in shape — a new type is a new module plus a new allowed type value; it needs no schema migration beyond the enum. Document, specification, and BDD are the three defined now.

Landing state (initial_state). A project also sets what state a new entry lands in when its idea-PR merges — super-draft (the normal propose→review→graduate flow) or active (graduated on submission). The default comes from the type: document and specification default to super-draft; bdd defaults to active — a behavior spec is captured fact, not a proposal under deliberation, so it skips the review-then-promote gate. It's an independent registry knob, so a deployment can override the default per project. This changes only the landing state and whether graduation is required; the propose→branch→PR engine is unchanged.

The unreviewed flag. Skipping straight to active means nothing vetted the entry, so it lands with an unreviewed flag set. An entry that reaches active the normal way — super-draft → graduate — is never flagged, because graduation is the review. A project owner clears the flag with a mark-reviewed action (same authority as graduate), and the catalog has an unreviewed filter so owners can find the entries still awaiting review. The flag is an entry property (frontmatter, git-truth like state), orthogonal to the active state and only meaningful on active entries.

The separation-of-concerns rule (CLAUDE.md) is satisfied: the type names and their behavior are framework concepts (like role names), not deployment-specific content. A deployment chooses which type each of its projects is; it does not define new types or rename them.

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 becomes naturally per-repo = per-project (and the slug is the whole identity — see Decision 3).

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
    type: document               # document | specification | bdd (immutable)
    content_repo: ohm-content    # repo under the deployment's Gitea org
    visibility: gated            # gated | public | unlisted
    initial_state: super-draft   # super-draft | active — landing state of a
                                 # new entry; defaults from type
    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). With slug-only identity (Decision 3) there is no per-project number to allocate: api_graduation.py's RFC-NNNN allocator is retired, and graduation reduces to the status flip + content-repo move, keyed on (project_id, slug).

New tables:

projects(
  id TEXT PRIMARY KEY,          -- 'ohm'
  name TEXT NOT NULL,
  type TEXT NOT NULL            -- document | specification | bdd (immutable)
    CHECK (type IN ('document','specification','bdd')),
  initial_state TEXT NOT NULL   -- super-draft | active (landing state, §types)
    DEFAULT 'super-draft' CHECK (initial_state IN ('super-draft','active')),
  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

  • Entry routes gain a project prefix with a generic /e/ segment: /p/<project>/e/<slug>, /p/<project>/proposals/<n>, /p/<project>/philosophy, etc. The segment is the same for every type; the noun shown around the slug is a type-driven label, not part of the path.
  • 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 / Human main (the entry is named by its slug; the entry-noun the chrome uses around it is type-driven — "RFC", "Spec", "Feature").
  • 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). Its id is a config-derived slug (DEFAULT_PROJECT_ID, else slug of the deployment name, else default); M1's default bootstrap id is re-stamped to it in M3 before any /p/ URL is 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-id>/e/<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).
  • Type surfaces — depth of each. What concretely is in the specification release-planning surface (its own tables? a release = a tag + a changelog entry + a set of graduated entries?), and does the bdd scenario model stay free-form markdown or get a structured Given/When/Then schema the app parses? Drafted shallow; pin before the type-surface slice.
  • Entry-noun in URLs/labelsresolved 2026-06-02: generic route segment /p/<project>/e/<slug> for every type; the displayed noun ("RFC"/"Spec"/"Feature") is a type-driven label, not part of the path (§22.10, §22.4a).
  • Existing graduated numbersresolved 2026-06-02: pre-change graduated entries keep their RFC-NNNN id in frontmatter as a frozen, read-only legacy display label (preserves citations); never used for lookup, never assigned to new entries (§22.4).
  • Default project idresolved 2026-06-02: a config-derived slug (DEFAULT_PROJECT_ID, else slug of the deployment name, else default); M1's default bootstrap id is re-stamped in M3 before any /p/ URL is public, so it's meaningful (e.g. /p/ohm/) and never renamed live (§22.13).