Builds the three-tier authorization resolver on M1's schema spine: the most-permissive union of deployment role (§6.1), project role (§22.6), and per-RFC authority (§6.3/§12), with the §22.5 visibility gate subtractive on top (§22.7). Pure app-layer — no migration (M1 shipped the tables), no behavior change on the public default project. Verifiable on the single default project by flipping its visibility and granting/revoking roles. - app/auth.py: the §22.7 resolver primitives — project_visibility, project_member_role, project_of_rfc, is_project_superuser, can_read_project, effective write/discuss standing (can_contribute_in_project / can_discuss_in_project), require_project_readable, visible_project_ids. The three per-RFC capability helpers (can_discuss_rfc / can_contribute_to_rfc / can_invite_to_rfc) now compose all three tiers, so the ~20 call sites inherit M2 unchanged. - Read pass: the §22.5 visibility gate (404 to non-members) threaded into every RFC-resolution helper across api.py / api_branches / api_prs / api_graduation / api_discussion / api_contributions / api_invitations, plus the catalog + proposals listings filtered by visible_project_ids. - Write pass: the deep gates (branch read/contribute/owner, PR merge/withdraw/ edit, graduation, discussion resolve) fold in project_admin via is_project_superuser; the "any-contributor" branch mode routes through can_contribute_in_project; propose + claim gate on project contribute standing. Deployment-level surfaces (admin idea-PR merge/decline, account notification-mute) stay deployment-scoped. - Operator decisions: implicit-on-public (a granted deployment contributor keeps its pre-M2 write baseline on a public project, no membership row, so the N=1 case stays whole) and preserve-curation (that baseline does not override per-RFC owner curation — only an explicit project grant or a deployment owner/admin does), keeping the v0.16.0 per-RFC invite contract intact on public. - tests: test_multi_project_authz_vertical.py — 9 vertical assertions on the resolver tiers, public-unchanged regression, the gated 404 read gate, the gated contribution gate, union + subtractive visibility, and revocation. Full suite 390 passed. - docs: mark Part C M2 "(landed)" with the two operator decisions. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
33 KiB
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 newdocs/DEV.mdbuild section. Rationale and the decisions behind this live inmulti-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 §§1–21 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 (§§9–13), the same threads,
flags, and chat. Type selects exactly three things:
- the entry frontmatter schema the project validates entries against (§2);
- the terminology the chrome uses for an entry (the §8.1 noun, catalog labels);
- 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 adocumentproject, so the N=1 case is unchanged.specification— a versioned technical specification (this framework's ownSPEC.mdis the archetype: numbered normative sections, upgrade steps). Frontmatter adds spec metadata (version, lifecyclestatusof 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 thespecificationentries 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 fordocumentandspecification) — a new entry lands as a super-draft and must be explicitly graduated (§13) to reachactive. This is today's flow: propose → super-draft → review → graduate.active(default forbdd) — a new entry landsactiveon the idea-PR merge, with theunreviewedflag 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 anyactiveentry.
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
activeby 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
activeviainitial_state: active(§22.4b) lands withunreviewed = true, because nothing reviewed it.
A project owner — project_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 aproject_contributorgrant. 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 byGET /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:
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-RFCdiscussant, at project scope.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.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_adminis 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/adminare superusers in every project; a plain authenticatedcontributorhas, 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, §12rfc_collaborators).
Concretely: deployment owner/admin ⊇ project_admin ⊇ RFC
owners/arbiters; deployment contributor + project_contributor ⊇ RFC
rfc_collaborators(contributor); project_viewer ⊇ discussant. 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 aproject_membersrow 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
unlistedproject, or by out-of-band referral) can request membership; aproject_adminaccepts or declines from the inbox. The request names the desired role (defaulting toproject_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 deploymentname,tagline, and the list of projects the caller can see (gated projects filtered by the caller's membership;unlistedomitted).GET /api/projects/:id— that project'sname,tagline, philosophy pointer, and optionalthemetoken overrides applied over the §-defaulttokens.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:
- The migration generates a
projectsrow 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 theida config-derived slug:DEFAULT_PROJECT_IDif set, else a slug of the deployment name, falling back to the literaldefault. (M1's migration 025 seeds the bootstrap iddefault; 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.) - Every existing RFC-scoped row (§5 amendment list) is stamped with that
project_id. - 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. - 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-NNNNmax+1allocation is removed — the slug is the identity (§22.4); there is no per-project number. Entries graduated before this change keep theiridas a frozen, read-only legacy display label (§22.4), never used for lookup. The entry frontmatter schema becomes type-dependent (§22.4a):documentkeeps today's fields,specificationandbddadd their type metadata. Newactive-entry fields:unreviewed(bool) and thereviewed_at/reviewed_byprovenance pair (§22.4c), parallelinggraduated_at/graduated_by. - §2.4 State machine. The
(no entry) ─[idea-PR merged]→transition now targets the project'sinitial_state(§22.4b):super-draftas today, or straight toactivewhen the project (e.g. abddproject) lands entries there — in which case the entry is stampedunreviewed(§22.4c). A newactive ─[mark-reviewed, owner/admin]→ activeself-transition clears the flag. The rest of the machine is unchanged. - §5 Data model. Add
project_idto:branch_visibility,branch_contribute_grants,stars,threads,changes,watches,notifications,rfc_invitations,rfc_collaborators,contribution_requests,funder_consents, the*_seencursors,actions, and the §4 cache tablescached_rfcs(PK →(project_id, slug); also mirrors theunreviewedfrontmatter 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 tablesprojects(carrying the immutabletype, §22.4a) andproject_members(§22.2, §22.6).users.roleis 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
contributorhas 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 ofactiveentries 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-NNNNallocation is gone. Graduation is also conditional on the project'sinitial_state(§22.4b): a project that lands entriesactivehas 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 clearsunreviewedinstead 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 fromGET /api/deployment, notVITE_APP_NAME. - §17 Backend surface. RFC routes gain the
/p/<project>/project_idscoping; addGET /api/deployment,GET /api/projects/:id(returns the project'stype, §22.4a), and theproject_membersmanagement + request-to-join endpoints (§22.6, §22.8). Add a mark-reviewed endpoint clearing an entry'sunreviewedflag (§22.4c, owner/admin), and anunreviewedfilter param on the catalog list. Type- specific surfaces (§22.4a) add their own routes, mounted only for projects of the matching type — e.g. thespecificationrelease-planning endpoints and thebddscenario/coverage endpoints. - §18 Stack.
ENABLED_MODELSis the deployment fallback; per-projectenabled_modelsoverrides it (§22.12). - §20 Versioning / surface.
VITE_APP_NAMEdeprecated in favor of the registry +GET /api/deployment(§22.9). New required backend env varREGISTRY_REPO(the §20.3 env contract);META_REPObecomes legacy, consulted only by the §22.13 migration to seed the default project'scontent_repo, then unused. The registry file shape and theprojects/project_membersschema 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 theMETA_REPOoverlay value forREGISTRY_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); M1–M4 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 025); 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 025'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 025 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 M1–M3 — 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-repo — resolved: a dedicated
registry repo the framework reads via the
REGISTRY_REPOenv 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 theMETA_REPOoverlay value forREGISTRY_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
specificationrelease-planning data model and whetherbddscenarios are free-form markdown or a parsed Given/When/Then structure. Pinned in M5.