- 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>
19 KiB
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)
- 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 aprojectscache table. Keeps the git-is-truth invariant. - Projects are membership-gated by default (private). A non-member does
not see a private project exists.
visibilityis still a per-project field withpublicandunlistedescape hatches (see §3) — gated is the default, not the only mode. - 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-projectRFC-NNNNallocation idea — graduation (§13) still flips an entry's status, but no longer mints a number. - Each project declares a
type—document,specification, orbdd. 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 adocumentproject.specification— a versioned technical specification (this app's ownSPEC.md, with numbered normative sections and upgrade steps, is the archetype). Frontmatter adds spec metadata (e.g.version, lifecyclestatusof 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 thespecificationentries 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-flotillaoperator 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_idunique per projectcached_branches,cached_prs,pr_resolution_branches,proposed_use_casesthreads,changes,branch_visibility,branch_contribute_grantsstars,watches,pr_seen,branch_chat_seenrfc_invitations,rfc_collaborators,contribution_requests,funder_consentsnotifications,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_contributorgrant. - 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/unlistedmodes 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:
- 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, likelypublic). Itsidis a config-derived slug (DEFAULT_PROJECT_ID, else slug of the deployment name, elsedefault); M1'sdefaultbootstrap id is re-stamped to it in M3 before any/p/URL is public. - Every existing row's
project_idis stamped to that default project. - 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_REPOenv var, the multi-project successor toMETA_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_REPOin 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_MODELSresolution 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
specificationrelease-planning surface (its own tables? a release = a tag + a changelog entry + a set of graduated entries?), and does thebddscenario 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/labels — resolved 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 numbers — resolved 2026-06-02: pre-change graduated
entries keep their
RFC-NNNNidin frontmatter as a frozen, read-only legacy display label (preserves citations); never used for lookup, never assigned to new entries (§22.4). - Default project
id— resolved 2026-06-02: a config-derived slug (DEFAULT_PROJECT_ID, else slug of the deployment name, elsedefault); M1'sdefaultbootstrap 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).