# 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 `type`** — `document`, `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:

```yaml
# 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_REPO`
   → `content_repo`, `VITE_APP_NAME` → `name`, 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_REPO`→`REGISTRY_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/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-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 `id`** — *resolved 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).
