Files
rfc-app/docs/design/multi-project-spec.md
T
Ben Stull 1dab24eef0 Merge feat/multi-project (§22 M1+M2) into main
Integrates the multi-project work so far — the §22 design drafts, M1 (the
schema spine), and M2 (project-scoped authorization + the §22.7 resolver) —
on top of the v0.32.0 retire/§13 changes that landed on main meanwhile.

Integration decisions:
- Renumbered the M1 projects migration 025_projects.sql -> 026_projects.sql.
  v0.32.0 shipped 025_retired_state.sql, which rebuilds cached_rfcs and
  predates the project_id column; running projects *after* the retire rebuild
  is required so project_id survives on a fresh database (the runner applies
  *.sql in filename order). Comment/doc references bumped to match.
- Resolved the get_rfc conflict in api.py by composing both gates: compute the
  viewer once, apply the §22.5 visibility gate, then v0.32.0's §13.7
  retired-entry owner-only check.
- api_graduation.py / api_discussion.py auto-merged cleanly (M2's threaded
  viewer/visibility gate coexists with the new retire endpoints + retired
  state).

Full suite 401 passed on a fresh DB.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 04:56:39 -07:00

33 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 entries under rfcs/, with its own per-project slug namespace (the slug is the identity — §22.4), a declared type (§22.4a), 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
    type: document               # document | specification | bdd — immutable (§22.4a)
    content_repo: ohm-content    # repo under the deployment's Gitea org (§22.3)
    visibility: gated            # gated | public | unlisted (§22.5)
    initial_state: super-draft   # super-draft | active — landing state of a new
                                 # entry; defaults from type (§22.4b)
    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 The slug namespace is per-project; the slug is the identity

An entry's slug (§2) is unique within its project, not across the deployment: ohm and specs may each have an intro. The slug is the identity — a fully qualified reference is (project_id, slug), and nothing more. There is no type prefix and no per-project numeric ID: within a project the slug alone is unambiguous, and the project context (its /p/<id>/ URL prefix and chrome) carries everything the old RFC-NNNN label used to.

This retires the per-project numbering of earlier drafts. The §13 graduation flip still happens — it moves an entry from proposal to graduated state and into the content repo — but it no longer allocates a number; the max(integer IDs)+1 allocator (§2.3, the old api_graduation.py path) is removed. The displayed noun around a slug ("RFC", "Spec", "Feature") is a presentation concern driven by the project's type (§22.4a), not part of the identity.

Legacy numbers. Entries graduated before this change keep their existing id (RFC-NNNN) in frontmatter as a frozen, non-identity legacy label — preserved and shown in the UI so external "RFC-0001"-style citations still resolve, but never used for routing or lookup (the slug is). New entries are never assigned one, and graduation does not write id. The field is read-only provenance from here on.

22.4a Project type

Every project declares a type in the registry (§22.2), chosen at creation and immutable: one of document, specification, or bdd. Type does not change the engine — every type uses the same content repo (§22.3), the same propose→branch→PR→discuss→graduate lifecycle (§§913), the same threads, flags, and chat. Type selects exactly three things:

  1. the entry frontmatter schema the project validates entries against (§2);
  2. the terminology the chrome uses for an entry (the §8.1 noun, catalog labels);
  3. the set of type-specific surfaces layered on top of the shared §7 catalog.

Type-specific behavior is implemented as a per-type module the framework selects on project.type; the engine itself treats every entry as markdown + frontmatter regardless of type. type is an open set in shape — a future type is a new module plus a new allowed enum value, no schema rebuild — and the three below are what is defined now. The type names and their behavior are framework concepts (like role names), not deployment content: a deployment picks which type each project is, but does not define or rename types.

  • document — long-form normative prose (OHM: a model of principles and definitions). Frontmatter is the §2 baseline (title, status, owners/arbiters, tags). No type-specific surfaces. The §22.13 generated default project is a document project, so the N=1 case is unchanged.
  • specification — a versioned technical specification (this framework's own SPEC.md is the archetype: numbered normative sections, upgrade steps). Frontmatter adds spec metadata (version, lifecycle status of draft/active/superseded, supersedes). Type-specific surface — release planning: group entries/changes into versioned releases, carry a changelog + §20-style upgrade-steps per release, and surface "what is in the next release."
  • bdd — behavior-driven feature specs: each entry states a feature as Given/When/Then scenarios 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 runs a BDD project alongside a specification project — a coverage view mapping features to the spec sections they exercise.

Draft note. The shared-engine boundary is locked; the per-type schemas and surfaces above (notably the specification release-planning data model and whether BDD scenarios are free-form markdown or a parsed Given/When/Then structure) are first proposals, to be pinned in the type-surface slice (Part C, M5).

22.4b Initial state of a new entry

A project sets the landing state a new entry takes when its creating idea-PR merges (§2.4) — the initial_state registry field, one of the §2.4 super-draft entry-states:

  • super-draft (default for document and specification) — a new entry lands as a super-draft and must be explicitly graduated (§13) to reach active. This is today's flow: propose → super-draft → review → graduate.
  • active (default for bdd) — a new entry lands active on the idea-PR merge, with the unreviewed flag set (§22.4c). The §13 graduate gate is not surfaced — the entry is already active — but because nothing reviewed it, the flag marks it as not-yet-vetted until an owner clears it. A behavior spec is captured fact, not a proposal under deliberation, so it skips the review-then-promote ceremony; branches, PRs, and discussion work afterward exactly as on any active entry.

The default comes from the project's type (§22.4a) — each type module supplies it — but initial_state is an independent registry knob: a deployment may run a bdd project with super-draft if it wants a review gate, or (less commonly) a document project that auto-actives. It changes only the landing state and whether graduation is required; the underlying propose→branch→PR→discuss engine is unchanged (shared-engine rule, §22.4a). The §22.13 default project keeps super-draft, preserving the N=1 flow.

22.4c The unreviewed flag

An active entry carries an unreviewed boolean, orthogonal to its state. It records whether a human gate has vetted the entry:

  • An entry that reaches active by the normal graduate path (§13) is never flagged — the graduate action, taken by an owner/admin, is the review.
  • An entry that skips straight to active via initial_state: active (§22.4b) lands with unreviewed = true, because nothing reviewed it.

A project ownerproject_admin, or a deployment owner/admin (§22.7) — clears the flag with a mark-reviewed action (§17), the same authority that graduates an entry. Clearing stamps reviewed_at/reviewed_by for provenance, paralleling graduated_at/graduated_by. The flag is a property of the entry (frontmatter, §2 amendment), so it is git-truth and survives a cache rebuild, exactly like state.

The §7 catalog gains an unreviewed filter so an owner can find the entries awaiting review; it is the natural worklist for the mark-reviewed action. unreviewed only applies to active entries — a super-draft is pre-review by definition, and withdrawn is out of scope.

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 entry view at /p/<project>/e/<slug>, the §9/§10 /p/<project>/proposals/<n>, and the §14 /p/<project>/philosophy. The entry segment is the generic /e/ for every type — the displayed noun ("RFC", "Spec", "Feature") is a type-driven label (§22.4a), not part of the path, so routing stays a single type-agnostic path and avoids colliding with the reserved sibling segments (proposals, 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 / Human main (slug, with the type-driven noun as its label).

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), type = document (every pre-multi-project corpus is a document corpus), and the id a config-derived slug: DEFAULT_PROJECT_ID if set, else a slug of the deployment name, falling back to the literal default. (M1's migration 026 seeds the bootstrap id default; the §C-M3 step re-stamps it to the config-derived slug before any /p/<id>/ route is public, so the id is meaningful — e.g. /p/ohm/… — and never renamed after URLs go live. The id stays framework-generic: the framework supplies no deployment name.)
  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-id>/… equivalents — the entry view to /p/<default-id>/e/<slug> (§22.10) — 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 RFC-NNNN max+1 allocation is removed — the slug is the identity (§22.4); there is no per-project number. Entries graduated before this change keep their id as a frozen, read-only legacy display label (§22.4), never used for lookup. The entry frontmatter schema becomes type-dependent (§22.4a): document keeps today's fields, specification and bdd add their type metadata. New active-entry fields: unreviewed (bool) and the reviewed_at/reviewed_by provenance pair (§22.4c), paralleling graduated_at/graduated_by.
  • §2.4 State machine. The (no entry) ─[idea-PR merged]→ transition now targets the project's initial_state (§22.4b): super-draft as today, or straight to active when the project (e.g. a bdd project) lands entries there — in which case the entry is stamped unreviewed (§22.4c). A new active ─[mark-reviewed, owner/admin]→ active self-transition clears the flag. The rest of the machine is unchanged.
  • §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); also mirrors the unreviewed frontmatter flag, §22.4c, so the §7 catalog filter can query it without reading every entry file), cached_branches, cached_prs, pr_resolution_branches, proposed_use_cases. Add the new tables projects (carrying the immutable type, §22.4a) 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. The catalog gains an unreviewed filter (§22.4c) — the owner's worklist of active entries that landed unreviewed.
  • §8.1 Breadcrumb. Gains a leading project segment (§22.10). The entry is named by its slug, not a number (§22.4); the noun shown around it ("RFC", "Spec", "Feature") is the project type's label (§22.4a).
  • §13.3 Graduation flip. Operates on the project's content repo. It flips status and moves the entry, but allocates no number — the slug is the identity throughout (§22.4); the old per-project RFC-NNNN allocation is gone. Graduation is also conditional on the project's initial_state (§22.4b): a project that lands entries active has no super-draft phase, so the graduate action is a no-op there and is not surfaced. For those entries the mark-reviewed action (§22.4c) takes graduation's place as the owner/admin vetting step — it clears unreviewed instead of flipping state.
  • §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 (returns the project's type, §22.4a), and the project_members management + request-to-join endpoints (§22.6, §22.8). Add a mark-reviewed endpoint clearing an entry's unreviewed flag (§22.4c, owner/admin), and an unreviewed filter param on the catalog list. Type- specific surfaces (§22.4a) add their own routes, mounted only for projects of the matching type — e.g. the specification release-planning endpoints and the bdd scenario/coverage endpoints.
  • §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

Seven 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). The project type (§22.4a) rides M3 (config) and M5 (its surfaces); M1M4 are type-agnostic because the engine is.

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 026); 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 026's header). This is the foundation everything after builds on.

M2 — Project-scoped authorization + the §22.7 resolver. (landed) 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; verifiable by granting/revoking roles on the default project and flipping its visibility (backend/tests/test_multi_project_authz_vertical.py). Pure app-layer — the resolver primitives live in app/auth.py (project_visibility, project_member_role, is_project_superuser, can_read_project, can_contribute_in_project, require_project_readable, visible_project_ids), composed into the existing per-RFC capability helpers and threaded into every RFC-resolution gate (_require_rfc*, _require_super_draft, _require_rfc_readable, the branch/PR/graduation deep gates). No migration (M1 shipped the tables) and no behavior change on the public default project.

Two operator decisions pin how the implicit grant behaves on a public project: implicit-on-public — a granted deployment contributor keeps its pre-multi-project write baseline with no project_members row, so the N=1 case stays whole (no backfill); and preserve curation — that implicit baseline does not override per-RFC owner curation (only an explicit project_contributor/admin or a deployment owner/admin does), so the v0.16.0 per-RFC invite contract is unchanged on public. Explicit project_members rows and gated/unlisted visibility are where the new tier bites. This is a deliberate liberalization of §22.5's literal "contributing still requires a grant" for the public case; gated/unlisted honor the grant model exactly.

M3 — Registry mirror + routing + runtime branding. The §4 registry mirror (webhook + reconciler over the REGISTRY_REPO, populating projects rows beyond the default); the re-stamp of the default project's bootstrap id (default → the config-derived slug, §22.13 step 1) which must land here, before any /p/<id>/ URL is public; the /p/<project>/ route prefix with the generic /e/<slug> entry segment (§22.10) and the 308 redirects off the old corpus-root URLs (/rfc/<slug>/p/<default-id>/e/<slug>); 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. This slice also adds the additive type and initial_state columns to projects (a small migration — M1 shipped projects without them), mirrors both from the registry, returns them on GET /api/projects/:id, and drives the entry-noun terminology off type (§22.4a). It also teaches the shared creation path to honor initial_state (§22.4b) — land a new entry active instead of super-draft, stamping unreviewed and skipping the graduate gate when the project says so — plus the unreviewed frontmatter fields (§22.4c) mirrored into cached_rfcs, the owner/admin mark-reviewed action, and the §7 catalog unreviewed filter that queries that cached column. But no type surfaces yet; beyond their label, landing state, and review flag, all three types still look the same here. 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 026 header) before a second project can collide with the first.

M4 — Per-project corpus surfaces (the second-project acceptance pass). The §7 catalog, §8 entry view, §9/§10 proposal/PR flows, §13 graduation, and §14 philosophy all confirmed working under project scope with the per-project, slug-only namespace (§22.4). This is mostly inherited from M1M3 — the work is an end-to-end pass that proves a second document project's full lifecycle (propose → super-draft → graduate, identified by slug in that project), not new build. Naming it explicitly as the acceptance slice keeps scope that belongs in M3 from leaking in.

M5 — Type modules + type-specific surfaces. The per-type layer of §22.4a: type-specific entry-frontmatter validation (specification/bdd metadata); the specification release-planning surface; the bdd scenario/coverage surface. Type-scoped routes mounted only for matching projects (§17). The shared engine is untouched — this slice only adds the layers on top, so a document project is unaffected and the M4 acceptance still holds. (The per-type schema/surface details are the §22.4a draft note's open work.)

M6 — 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.

M7 — 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); M6 (membership lifecycle) may ship invite-only first and add request-to-join second if scope demands.
  • Per-type schemas and surfaces — the §22.4a draft note's open work: the specification release-planning data model and whether bdd scenarios are free-form markdown or a parsed Given/When/Then structure. Pinned in M5.