From 7aba89655df452d128fdb63480ed4562146f77c2 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Mon, 1 Jun 2026 18:05:13 -0700 Subject: [PATCH 1/5] =?UTF-8?q?docs(design):=20sketch=20multi-project=20de?= =?UTF-8?q?ployments=20+=20draft=20=C2=A722=20spec=20&=20slicing=20plan?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Design for hosting N projects (corpora) per deployment, with today's single-corpus deployment as the N=1 case. Captures the locked decisions (git registry, gated-by-default visibility, per-project RFC numbering), the three-tier role model (deployment / project / per-RFC), and the forced runtime-branding shift off VITE_APP_NAME. - multi-project.md: rationale, decisions, operator (flotilla) integration. - multi-project-spec.md: draft binding §22, in-place amendments to §§1,2,5,6,7,8,13,14,17,18,20, and a six-slice (M1-M6) build plan. Registry lives in a deployment-side repo the framework reads via a new REGISTRY_REPO env var (META_REPO's multi-project successor); confirmed against the ohm-rfc-app-flotilla spec — no operator-tooling change needed. Not yet merged into SPEC.md or versioned; folds in when M1 lands. Co-Authored-By: Claude Opus 4.8 --- docs/design/multi-project-spec.md | 371 ++++++++++++++++++++++++++++++ docs/design/multi-project.md | 255 ++++++++++++++++++++ 2 files changed, 626 insertions(+) create mode 100644 docs/design/multi-project-spec.md create mode 100644 docs/design/multi-project.md diff --git a/docs/design/multi-project-spec.md b/docs/design/multi-project-spec.md new file mode 100644 index 0000000..230fbc7 --- /dev/null +++ b/docs/design/multi-project-spec.md @@ -0,0 +1,371 @@ +# 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`](./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 RFC entries under `rfcs/`, with +its own slug and `RFC-NNNN` namespaces, 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. + +```yaml +# 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 + content_repo: ohm-content # repo under the deployment's Gitea org (§22.3) + visibility: gated # gated | public | unlisted (§22.5) + 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 `-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 Slug and ID namespaces are per-project + +An RFC's slug (§2) is unique **within its project**, not across the +deployment: `ohm` and `specs` may each have an `intro`. `RFC-NNNN` integer +IDs are assigned per project at graduation as `max(existing integer IDs in +this project) + 1` (§13, §2.3 amended) — each project numbers from `RFC-0001` +independently. A fully qualified RFC reference is therefore +`(project_id, slug)`, and after graduation `(project_id, RFC-NNNN)`. + +### 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`/`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 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//…` carries +the §7 catalog, the §8 `/p//rfc/`, the §9/§10 +`/p//proposals/`, and the §14 `/p//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 / RFC-0042 · Human › main`. + +### 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), `id` a + slug derived from the deployment. +2. Every existing RFC-scoped row (§5 amendment list) is stamped with that + `project_id`. +3. Old corpus-root URLs (`/rfc/`, `/proposals/`) 308-redirect to + their `/p//…` equivalents, 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 `max+1` is scoped to the + project (§22.4). +- **§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)`, `rfc_id` + unique per project), `cached_branches`, `cached_prs`, + `pr_resolution_branches`, `proposed_use_cases`. Add the new tables + `projects` 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//`. The + deployment directory (§22.10) is a new surface above it. +- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10). +- **§13.3 Graduation flip.** Operates on the project's content repo; + `RFC-NNNN` is allocated per project (§22.4). +- **§14.1 Pre-login landing.** Splits into deployment landing (the directory, + §22.10) and per-project philosophy/deck (§14 under `/p//`). + Deployment name comes from `GET /api/deployment`, not `VITE_APP_NAME`. +- **§17 Backend surface.** RFC routes gain the `/p/` / + `project_id` scoping; add `GET /api/deployment`, `GET /api/projects/:id`, + and the `project_members` management + request-to-join endpoints (§22.6, + §22.8). +- **§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 + +Six 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). + +**M1 — The project spine (schema + registry + default-project migration).** +The `projects` and `project_members` tables; the §4 registry mirror (webhook ++ reconciler over the registry repo); `project_id` threaded through every §5 +table per the Part B list; the §22.13 migration that generates the default +project, backfills `project_id`, and stands up the redirect. No UI yet — the +app runs exactly as before, single project, with the spine underneath. This +is the slice that must land atomically; everything after is additive. + +**M2 — Project-scoped authorization + the §22.7 resolver.** 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; this slice +is verifiable by granting/revoking roles on the default project and asserting +the gates. + +**M3 — Routing + runtime branding.** The `/p//` route prefix, the +308 redirects, `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. After M3 a deployment with two registry projects is fully +navigable. + +**M4 — Per-project corpus surfaces.** The §7 catalog, §8 RFC view, §9/§10 +proposal/PR flows, §13 graduation, and §14 philosophy all confirmed working +under project scope with per-project slug/ID namespaces (§22.4). Mostly +inherited from M1–M3; this slice is the end-to-end pass that proves a second +project's full lifecycle (propose → super-draft → graduate → `RFC-0001` *in +that project*). + +**M5 — 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. + +**M6 — 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_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); M5 may + ship invite-only first and add request-to-join second if scope demands. diff --git a/docs/design/multi-project.md b/docs/design/multi-project.md new file mode 100644 index 0000000..3e622bb --- /dev/null +++ b/docs/design/multi-project.md @@ -0,0 +1,255 @@ +# 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/`RFC-NNNN` namespace, 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 becomes one project among several (specs, vision docs, …) 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. **RFC numbering is per-project.** Each project has its own `RFC-0001`, + `RFC-0002`… computed as `max(existing IDs in this project) + 1`. + +## 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 and `RFC-NNNN` +sequences become naturally per-repo = per-project. + +Rejected alternatives: subdirectories in one repo (`projects//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 + content_repo: ohm-content # repo under the deployment's Gitea org + visibility: gated # gated | public | unlisted + 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). +`api_graduation.py`'s `RFC-NNNN` allocator scopes its `max()` query by +`project_id`. + +New tables: + +``` +projects( + id TEXT PRIMARY KEY, -- 'ohm' + name TEXT NOT NULL, + 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 + +- RFC routes gain a project prefix: `/p//rfc/`, + `/p//proposals/`, `/p//philosophy`, etc. +- 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 / RFC-0042 · Human › main`. +- 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`). +2. Every existing row's `project_id` is stamped to that default project. +3. An optional default-project redirect keeps old `/rfc/` URLs alive + (308 → `/p//rfc/`). + +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). From 6f901e3e2cef8f00da351d3e0b4770007c8a05ca Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Mon, 1 Jun 2026 18:14:53 -0700 Subject: [PATCH 2/5] =?UTF-8?q?feat(projects):=20M1=20=E2=80=94=20the=20mu?= =?UTF-8?q?lti-project=20schema=20spine=20(=C2=A722)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Lays the additive foundation for hosting N projects per deployment, with today's single corpus as the N=1 case. No behavior change: the app runs exactly as before, single project, with the spine underneath. - migration 025: `projects` + `project_members` tables; seed the `default` project (visibility=public, preserving open-by-default); thread `project_id NOT NULL DEFAULT 'default'` onto all 19 slug-bearing tables, backfilling existing rows. Additive — no table rebuilds; the slug-keyed uniqueness/PK rework is enumerated in the migration header and deferred to the slice that activates project #2. - app/projects.py: §22.13 startup backfill of the default project's content_repo from META_REPO (idempotent — never clobbers a value the future registry mirror sets). - config: REGISTRY_REPO wired (optional through M1; consumed by the M3 mirror), documented in .env.example as META_REPO's successor. - tests: 6 vertical assertions on the spine (seed, backfill, column shape, role CHECK, idempotency, config). Full suite 381 passed. - docs: align Part C M1/M3 boundaries with the landed code (registry mirror + redirect move to M3). Co-Authored-By: Claude Opus 4.8 --- backend/.env.example | 9 ++ backend/app/config.py | 8 ++ backend/app/main.py | 2 + backend/app/projects.py | 40 ++++++ backend/migrations/025_projects.sql | 105 ++++++++++++++ .../test_multi_project_spine_vertical.py | 133 ++++++++++++++++++ docs/design/multi-project-spec.md | 34 +++-- 7 files changed, 318 insertions(+), 13 deletions(-) create mode 100644 backend/app/projects.py create mode 100644 backend/migrations/025_projects.sql create mode 100644 backend/tests/test_multi_project_spine_vertical.py diff --git a/backend/.env.example b/backend/.env.example index 4164dce..245371b 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -14,6 +14,15 @@ GITEA_BOT_TOKEN= GITEA_ORG=wiggleverse META_REPO=meta +# --- Multi-project registry (§22) --- +# The registry repo (under GITEA_ORG) the framework reads to learn which +# projects this deployment hosts. Optional through Slice M1: the registry +# mirror lands in M3, and until then the single default project's content +# repo is taken from META_REPO above. The repo's name is the deployment's +# choice. When the mirror lands, REGISTRY_REPO becomes required and +# supersedes META_REPO. +# REGISTRY_REPO=wiggleverse-registry + # --- OAuth (Gitea) --- # In Gitea: Site Administration → Applications → Add OAuth2 Application. # Redirect URI: {APP_URL}/auth/callback diff --git a/backend/app/config.py b/backend/app/config.py index e1c1bdd..014c19e 100644 --- a/backend/app/config.py +++ b/backend/app/config.py @@ -33,6 +33,7 @@ class Config: gitea_bot_token: str gitea_org: str meta_repo: str + registry_repo: str oauth_client_id: str oauth_client_secret: str app_url: str @@ -80,6 +81,13 @@ def load_config() -> Config: gitea_bot_token=_required("GITEA_BOT_TOKEN"), gitea_org=_required("GITEA_ORG"), meta_repo=_optional("META_REPO", "meta"), + # §22.2: the multi-project registry repo the framework reads to learn + # which projects exist. Optional through Slice M1 — the registry + # *mirror* lands in M3; until then the default project (seeded by + # migration 025 + the §22.13 startup backfill) is the only project, + # and its content_repo comes from META_REPO. Becomes required when + # the mirror lands and META_REPO is retired. + registry_repo=_optional("REGISTRY_REPO"), oauth_client_id=_required("OAUTH_CLIENT_ID"), oauth_client_secret=_required("OAUTH_CLIENT_SECRET"), app_url=_optional("APP_URL", "http://localhost:8000").rstrip("/"), diff --git a/backend/app/main.py b/backend/app/main.py index f580a28..3f904ce 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -28,6 +28,7 @@ from . import ( invites as invites_mod, otc, passcode as passcode_mod, + projects, providers as providers_mod, ratelimit, turnstile, @@ -99,6 +100,7 @@ async def lifespan(app: FastAPI): config = load_config() db.run_migrations(config) db.init(config) + projects.seed_default_project(config) # §22.13 step 1 gitea = Gitea(config) bot = Bot(gitea) reconciler = cache.Reconciler(config, gitea) diff --git a/backend/app/projects.py b/backend/app/projects.py new file mode 100644 index 0000000..d614a55 --- /dev/null +++ b/backend/app/projects.py @@ -0,0 +1,40 @@ +"""Project registry — the §22 multi-project layer. + +A deployment hosts one or more projects (§22.1). Through Slice M1 the only +project is the migration-seeded `default` one (the N=1 case, §22.13); the git +registry mirror that lets a deployment declare more projects lands in M3 and +will grow this module. For now this holds just the §22.13 step-1 backfill: +completing the default project's row from deployment config, which pure-SQL +migration 025 could not read. +""" +from __future__ import annotations + +from . import db +from .config import Config + +DEFAULT_PROJECT_ID = "default" + + +def seed_default_project(config: Config) -> None: + """Fill the default project's content_repo from config. + + Migration 025 seeds `default` with content_repo NULL because SQL cannot + read the environment. This sets it from META_REPO (the pre-multi-project + single-corpus repo) the first time the configured app boots, so the row + accurately names the corpus the existing rows belong to. Idempotent: only + touches the row while content_repo is still unset, so a later registry + mirror (M3) that has populated it is never clobbered. + + The display `name` is deliberately left as-is: the deployment's + user-visible name lives in the frontend build (VITE_APP_NAME) today and + moves to the registry + GET /api/deployment in M3 (§22.9); the backend has + no authoritative name to backfill from yet. + """ + db.conn().execute( + """ + UPDATE projects + SET content_repo = ?, updated_at = datetime('now') + WHERE id = ? AND content_repo IS NULL + """, + (config.meta_repo, DEFAULT_PROJECT_ID), + ) diff --git a/backend/migrations/025_projects.sql b/backend/migrations/025_projects.sql new file mode 100644 index 0000000..4d7b351 --- /dev/null +++ b/backend/migrations/025_projects.sql @@ -0,0 +1,105 @@ +-- §22 (multi-project deployments) — Slice M1: the project spine. +-- +-- A deployment now hosts one or more *projects*, each a corpus with its own +-- content repo, slug/RFC-NNNN namespace, catalog, roster, and branding. The +-- pre-multi-project single-corpus deployment is the N=1 case: this migration +-- generates one 'default' project and stamps every existing RFC-scoped row +-- to it, so the app keeps running exactly as before with the spine +-- underneath (see docs/design/multi-project-spec.md §22.13). +-- +-- STRATEGY — additive, no table rebuilds. Every slug-bearing table gets a +-- `project_id TEXT NOT NULL DEFAULT 'default'` column. The constant default +-- means every existing INSERT in the codebase that does not yet mention +-- project_id keeps working and lands rows in the default project; no query +-- breaks because, with a single project, slugs remain globally unique. The +-- column carries no inline REFERENCES clause: SQLite's ALTER TABLE ADD +-- COLUMN forbids a FK column with a non-NULL default. project_id referential +-- integrity is therefore enforced at the app layer for now; the FK lands +-- with the table rebuilds below. +-- +-- ============================================================================ +-- DEFERRED to the slice that activates project #2 (M3/M4). Until a second +-- project exists these are correct as-is; the moment two projects can share a +-- slug or a Gitea PR number they become cross-project collision bugs and MUST +-- be rebuilt (SQLite needs a create-copy-drop-rename per table) to fold +-- project_id into the key, and to add the project_id FK: +-- * cached_rfcs PRIMARY KEY (slug) -> (project_id, slug) +-- * cached_branches UNIQUE (rfc_slug, branch_name) -> +project_id +-- * branch_visibility UNIQUE (rfc_slug, branch_name) -> +project_id +-- * branch_contribute_grants UNIQUE (rfc_slug, branch_name, grantee_user_id) -> +project_id +-- * stars UNIQUE (user_id, rfc_slug) -> +project_id +-- * watches UNIQUE (user_id, rfc_slug) -> +project_id +-- * pr_seen UNIQUE (user_id, rfc_slug, pr_number) -> +project_id +-- * branch_chat_seen UNIQUE (user_id, rfc_slug, branch_name) -> +project_id +-- * funder_consents PRIMARY KEY (user_id, rfc_slug) -> +project_id +-- * rfc_collaborators UNIQUE INDEX (rfc_slug, user_id) -> +project_id +-- * contribution_requests UNIQUE INDEX (rfc_slug, requester_user_id) WHERE pending -> +project_id +-- * proposed_use_cases UNIQUE (scope, pr_number) -> +project_id +-- (PR numbers are per-content-repo = per-project) +-- cached_prs UNIQUE (repo, pr_number) is already globally unique (repo is the +-- full 'org/repo' string, distinct per project) and needs no rebuild. +-- ============================================================================ + +-- The project registry cache (mirrored from the git registry by the M3 +-- reconciler; rows are never written from user actions). content_repo is +-- NULL until the mirror — or the M1 startup backfill (§22.13 step 1) — sets +-- it from the deployment's configured repo. +CREATE TABLE IF NOT EXISTS projects ( + id TEXT PRIMARY KEY, + name TEXT NOT NULL, + content_repo TEXT, + visibility TEXT NOT NULL DEFAULT 'gated' + CHECK (visibility IN ('gated', 'public', 'unlisted')), + config_json TEXT, + registry_sha TEXT, + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +-- The default project. visibility='public' preserves the pre-multi-project +-- open-by-default posture (§22.5, §22.13). name is a placeholder the startup +-- backfill / registry overwrites with the deployment's display name. +INSERT OR IGNORE INTO projects (id, name, visibility) + VALUES ('default', 'default', 'public'); + +-- Per-(user, project) membership and the §22.6 middle-tier role. This is the +-- new tier between the §6.1 deployment role (users.role, now deployment-scope +-- only) and the §6.3 per-RFC authority. +CREATE TABLE IF NOT EXISTS project_members ( + project_id TEXT NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL DEFAULT 'project_viewer' + CHECK (role IN ('project_admin', 'project_contributor', 'project_viewer')), + granted_by INTEGER REFERENCES users(id) ON DELETE SET NULL, + granted_at TEXT NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (project_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members(user_id); + +-- The project_id spine across every slug-bearing table. Backfills existing +-- rows to 'default' via the constant column default. +ALTER TABLE cached_rfcs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE cached_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE cached_prs ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE branch_visibility ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE branch_contribute_grants ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE stars ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE threads ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE changes ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE pr_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE branch_chat_seen ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE watches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE notifications ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE actions ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE pr_resolution_branches ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE funder_consents ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE rfc_invitations ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE rfc_collaborators ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE proposed_use_cases ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; +ALTER TABLE contribution_requests ADD COLUMN project_id TEXT NOT NULL DEFAULT 'default'; + +-- The catalog/directory lookup the M3 surfaces will make (RFCs in a project). +-- The per-table composite-key rebuilds in the DEFERRED block above will add +-- their own (project_id, …) indexes when they land. +CREATE INDEX IF NOT EXISTS idx_cached_rfcs_project ON cached_rfcs(project_id); diff --git a/backend/tests/test_multi_project_spine_vertical.py b/backend/tests/test_multi_project_spine_vertical.py new file mode 100644 index 0000000..e387160 --- /dev/null +++ b/backend/tests/test_multi_project_spine_vertical.py @@ -0,0 +1,133 @@ +"""Slice M1 — the §22 multi-project spine. + +Migration 025 introduces the `projects` and `project_members` tables, seeds +the single `default` project (the N=1 case, §22.13), and threads a +`project_id` column onto every slug-bearing table, backfilled to `default`. +The §22.13 startup backfill then fills the default project's content_repo +from META_REPO. These tests prove the spine lands without disturbing the +single-project app. +""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from test_propose_vertical import ( # noqa: F401 + app_with_fake_gitea, + tmp_env, +) + +# The 19 tables migration 025 threads project_id onto (docs/design/ +# multi-project-spec.md §5 amendment list). +SLUG_TABLES = [ + "cached_rfcs", "cached_branches", "cached_prs", "branch_visibility", + "branch_contribute_grants", "stars", "threads", "changes", "pr_seen", + "branch_chat_seen", "watches", "notifications", "actions", + "pr_resolution_branches", "funder_consents", "rfc_invitations", + "rfc_collaborators", "proposed_use_cases", "contribution_requests", +] + + +def test_default_project_seeded_and_backfilled(app_with_fake_gitea): + from app import db + + app, _ = app_with_fake_gitea + with TestClient(app): + rows = list(db.conn().execute( + "SELECT id, name, visibility, content_repo FROM projects" + )) + assert len(rows) == 1 + row = rows[0] + assert row["id"] == "default" + # public preserves the pre-multi-project open-by-default posture. + assert row["visibility"] == "public" + # §22.13 startup backfill set content_repo from META_REPO (tmp_env). + assert row["content_repo"] == "meta" + + +def test_project_id_on_every_slug_table(app_with_fake_gitea): + from app import db + + app, _ = app_with_fake_gitea + with TestClient(app): + for table in SLUG_TABLES: + cols = {r["name"]: r for r in db.conn().execute( + f"PRAGMA table_info({table})" + )} + assert "project_id" in cols, f"{table} missing project_id" + col = cols["project_id"] + # NOT NULL with the constant 'default' backfill default. + assert col["notnull"] == 1, f"{table}.project_id should be NOT NULL" + assert col["dflt_value"] == "'default'", f"{table}.project_id default" + + +def test_existing_row_backfills_to_default(app_with_fake_gitea): + """A row inserted the old way (no project_id) lands in the default + project — the trick that keeps every pre-multi-project INSERT working.""" + from app import db + + app, _ = app_with_fake_gitea + with TestClient(app): + db.conn().execute( + "INSERT INTO cached_rfcs (slug, title, state) VALUES (?, ?, ?)", + ("human", "Human", "active"), + ) + got = db.conn().execute( + "SELECT project_id FROM cached_rfcs WHERE slug = 'human'" + ).fetchone()["project_id"] + assert got == "default" + + +def test_project_members_table_shape(app_with_fake_gitea): + from app import db + + app, _ = app_with_fake_gitea + with TestClient(app): + cols = {r["name"] for r in db.conn().execute( + "PRAGMA table_info(project_members)" + )} + assert cols == {"project_id", "user_id", "role", "granted_by", "granted_at"} + # The role CHECK rejects an unknown role. + db.conn().execute( + "INSERT INTO users (id, display_name, role) VALUES (1, 'Ben', 'owner')" + ) + db.conn().execute( + "INSERT INTO project_members (project_id, user_id, role) " + "VALUES ('default', 1, 'project_admin')" + ) + import sqlite3 + try: + db.conn().execute( + "INSERT INTO project_members (project_id, user_id, role) " + "VALUES ('default', 1, 'nonsense')" + ) + assert False, "CHECK should reject an unknown project role" + except sqlite3.IntegrityError: + pass + + +def test_seed_default_project_is_idempotent(app_with_fake_gitea): + """Re-running the backfill never clobbers a content_repo already set — + so a later registry mirror (M3) wins over the META_REPO fallback.""" + from app import db, projects + from app.config import load_config + + app, _ = app_with_fake_gitea + with TestClient(app): + db.conn().execute( + "UPDATE projects SET content_repo = 'ohm-content' WHERE id = 'default'" + ) + projects.seed_default_project(load_config()) + got = db.conn().execute( + "SELECT content_repo FROM projects WHERE id = 'default'" + ).fetchone()["content_repo"] + assert got == "ohm-content" + + +def test_registry_repo_config_wired(app_with_fake_gitea, monkeypatch): + from app.config import load_config + + monkeypatch.setenv("REGISTRY_REPO", "wiggleverse-registry") + assert load_config().registry_repo == "wiggleverse-registry" + # Optional through M1: absent resolves to empty, not a startup failure. + monkeypatch.delenv("REGISTRY_REPO", raising=False) + assert load_config().registry_repo == "" diff --git a/docs/design/multi-project-spec.md b/docs/design/multi-project-spec.md index 230fbc7..2e9ed08 100644 --- a/docs/design/multi-project-spec.md +++ b/docs/design/multi-project-spec.md @@ -315,13 +315,17 @@ 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). -**M1 — The project spine (schema + registry + default-project migration).** -The `projects` and `project_members` tables; the §4 registry mirror (webhook -+ reconciler over the registry repo); `project_id` threaded through every §5 -table per the Part B list; the §22.13 migration that generates the default -project, backfills `project_id`, and stands up the redirect. No UI yet — the -app runs exactly as before, single project, with the spine underneath. This -is the slice that must land atomically; everything after is additive. +**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.** The three-tier composition: `project_members` roles, the most-permissive union with @@ -331,12 +335,16 @@ re-checked under the project axis. Still single visible project; this slice is verifiable by granting/revoking roles on the default project and asserting the gates. -**M3 — Routing + runtime branding.** The `/p//` route prefix, the -308 redirects, `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. After M3 a deployment with two registry projects is fully -navigable. +**M3 — Registry mirror + routing + runtime branding.** The §4 registry +mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects` +rows beyond `default`); the `/p//` route prefix and the 308 +redirects off the old corpus-root URLs; `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. 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 §7 catalog, §8 RFC view, §9/§10 proposal/PR flows, §13 graduation, and §14 philosophy all confirmed working From 57b2fc5205957789e659fe6ba9b587811b2f623f Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Tue, 2 Jun 2026 18:26:52 -0700 Subject: [PATCH 3/5] =?UTF-8?q?docs(design):=20project=20types,=20slug-onl?= =?UTF-8?q?y=20identity,=20initial=5Fstate=20+=20unreviewed=20flag=20(?= =?UTF-8?q?=C2=A722=20draft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extends the §22 multi-project draft along four axes decided this session: - Project type — each project declares document | specification | bdd (immutable, registry-set). One shared propose→branch→PR→graduate engine; type only layers entry schema, terminology, and type-specific surfaces (spec release-planning, BDD scenarios/coverage). §22.4a. - Slug-only identity — an entry is (project_id, slug); the per-project RFC-NNNN numbering and its allocator are retired. §22.4, §2.3/§13 amended. - initial_state — per-project landing state for a new entry: super-draft (document/spec default) or active (bdd default). §22.4b. - unreviewed flag — entries that skip straight to active land unreviewed; graduation implies review so the normal path is never flagged. Owner mark-reviewed clears it; §7 catalog gains an unreviewed filter; mirrored into cached_rfcs. §22.4c. Slicing plan re-sliced six→seven: type/initial_state/unreviewed config ride M3, type surfaces become M5, membership→M6, hardening→M7. Co-Authored-By: Claude Opus 4.8 --- docs/design/multi-project-spec.md | 245 +++++++++++++++++++++++++----- docs/design/multi-project.md | 126 +++++++++++++-- 2 files changed, 317 insertions(+), 54 deletions(-) diff --git a/docs/design/multi-project-spec.md b/docs/design/multi-project-spec.md index 2e9ed08..d6faa46 100644 --- a/docs/design/multi-project-spec.md +++ b/docs/design/multi-project-spec.md @@ -14,8 +14,9 @@ ## 22. Projects: multiple corpora per deployment A **deployment** hosts one or more **projects**. A project is a single -corpus: one content repository (§1) holding RFC entries under `rfcs/`, with -its own slug and `RFC-NNNN` namespaces, catalog, philosophy, branding, +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. @@ -73,8 +74,11 @@ deployment: 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) ``` @@ -94,14 +98,116 @@ 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 Slug and ID namespaces are per-project +### 22.4 The slug namespace is per-project; the slug is the identity -An RFC's slug (§2) is unique **within its project**, not across the -deployment: `ohm` and `specs` may each have an `intro`. `RFC-NNNN` integer -IDs are assigned per project at graduation as `max(existing integer IDs in -this project) + 1` (§13, §2.3 amended) — each project numbers from `RFC-0001` -independently. A fully qualified RFC reference is therefore -`(project_id, slug)`, and after graduation `(project_id, RFC-NNNN)`. +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//` +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. + +### 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: + +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 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 @@ -237,8 +343,9 @@ 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), `id` a - slug derived from the deployment. + (preserving the deployment's current open-by-default posture), + `type = document` (every pre-multi-project corpus is a document corpus), + and `id` a slug derived from the deployment. 2. Every existing RFC-scoped row (§5 amendment list) is stamped with that `project_id`. 3. Old corpus-root URLs (`/rfc/`, `/proposals/`) 308-redirect to @@ -262,34 +369,63 @@ Applied in place, in the established amendment-note style (cf. §1's 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 `max+1` is scoped to the - project (§22.4). + 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. 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)`, `rfc_id` - unique per project), `cached_branches`, `cached_prs`, - `pr_resolution_branches`, `proposed_use_cases`. Add the new tables - `projects` and `project_members` (§22.2, §22.6). `users.role` is annotated - as deployment-scope (§22.7). + 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//`. The - deployment directory (§22.10) is a new surface above it. -- **§8.1 Breadcrumb.** Gains a leading project segment (§22.10). -- **§13.3 Graduation flip.** Operates on the project's content repo; - `RFC-NNNN` is allocated per project (§22.4). + 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//`). Deployment name comes from `GET /api/deployment`, not `VITE_APP_NAME`. - **§17 Backend surface.** RFC routes gain the `/p/` / - `project_id` scoping; add `GET /api/deployment`, `GET /api/projects/:id`, - and the `project_members` management + request-to-join endpoints (§22.6, - §22.8). + `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 @@ -309,11 +445,13 @@ Applied in place, in the established amendment-note style (cf. §1's # Part C — Slicing plan -Six slices carry §22 and its amendments end-to-end. The ordering mirrors +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). +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 @@ -341,24 +479,45 @@ rows beyond `default`); the `/p//` route prefix and the 308 redirects off the old corpus-root URLs; `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. 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. +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 §7 catalog, §8 RFC view, §9/§10 -proposal/PR flows, §13 graduation, and §14 philosophy all confirmed working -under project scope with per-project slug/ID namespaces (§22.4). Mostly -inherited from M1–M3; this slice is the end-to-end pass that proves a second -project's full lifecycle (propose → super-draft → graduate → `RFC-0001` *in -that project*). +**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 — Membership lifecycle.** §22.8 invite (re-scoped §12 machinery) and +**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. -**M6 — Hardening + operator path.** Per-project `enabled_models` resolution +**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 + @@ -375,5 +534,9 @@ upgrade-steps block; the SPEC merge (Part A appended, Part B applied). 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); M5 may - ship invite-only first and add request-to-join second if scope demands. +- **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. diff --git a/docs/design/multi-project.md b/docs/design/multi-project.md index 3e622bb..74cafab 100644 --- a/docs/design/multi-project.md +++ b/docs/design/multi-project.md @@ -15,13 +15,15 @@ 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/`RFC-NNNN` namespace, 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. +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 becomes one project among several (specs, vision docs, …) under one -deployment. +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: @@ -40,15 +42,89 @@ deployment-scoped today splits cleanly into: 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. **RFC numbering is per-project.** Each project has its own `RFC-0001`, - `RFC-0002`… computed as `max(existing IDs in this project) + 1`. +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 and `RFC-NNNN` -sequences become naturally per-repo = per-project. +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//rfcs/…`) churns every path / branch-name / graduation code path and can't carry @@ -67,8 +143,11 @@ deployment: 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 @@ -100,8 +179,10 @@ of them: - `notifications`, `actions` ~15-table migration, all backfillable to a single default project (see §7). -`api_graduation.py`'s `RFC-NNNN` allocator scopes its `max()` query by -`project_id`. +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: @@ -109,6 +190,10 @@ 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, … @@ -189,7 +274,9 @@ Two chrome layers result: - 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 / RFC-0042 · Human › main`. +- 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. @@ -253,3 +340,16 @@ This confirms the registry decision and fixes how it integrates: - 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.** Slug-only identity means no `RFC-NNNN`. Is + the route segment generic (`/p//e/`) with the displayed noun + type-driven, or does the segment itself vary by type? (Leaning: generic + path segment, type-driven label.) +- **Existing graduated numbers.** OHM already has `RFC-0001…` in filenames. + On dropping numeric identity, do those numbers survive as a display/legacy + artifact, or get normalized to slugs? (Migration/back-compat detail.) From ad2ece18fa53e79970cf75c1574bb0d3b565a9fb Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Tue, 2 Jun 2026 19:03:15 -0700 Subject: [PATCH 4/5] =?UTF-8?q?docs(design):=20resolve=20the=20three=20M3-?= =?UTF-8?q?blocking=20decisions=20(=C2=A722=20draft)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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//e/ 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 --- docs/design/multi-project-spec.md | 46 +++++++++++++++++++++++-------- docs/design/multi-project.md | 32 +++++++++++++-------- 2 files changed, 55 insertions(+), 23 deletions(-) diff --git a/docs/design/multi-project-spec.md b/docs/design/multi-project-spec.md index d6faa46..f1c1191 100644 --- a/docs/design/multi-project-spec.md +++ b/docs/design/multi-project-spec.md @@ -115,6 +115,13 @@ 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 @@ -312,12 +319,16 @@ 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//…` carries -the §7 catalog, the §8 `/p//rfc/`, the §9/§10 -`/p//proposals/`, and the §14 `/p//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 / RFC-0042 · Human › main`. +the §7 catalog, the §8 entry view at `/p//e/`, the §9/§10 +`/p//proposals/`, and the §14 `/p//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 @@ -345,11 +356,17 @@ single **default project** so it keeps running unchanged: `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 `id` a slug derived from the deployment. + 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 025 seeds the bootstrap id `default`; the §C-M3 step re-stamps it + to the config-derived slug before any `/p//` 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/`, `/proposals/`) 308-redirect to - their `/p//…` equivalents, so existing links survive. + their `/p//…` equivalents — the entry view to + `/p//e/` (§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. @@ -371,7 +388,9 @@ Applied in place, in the established amendment-note style (cf. §1's - **§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. The entry frontmatter schema becomes type-dependent + 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 @@ -475,9 +494,12 @@ the gates. **M3 — Registry mirror + routing + runtime branding.** The §4 registry mirror (webhook + reconciler over the `REGISTRY_REPO`, populating `projects` -rows beyond `default`); the `/p//` route prefix and the 308 -redirects off the old corpus-root URLs; `GET /api/deployment` and `GET -/api/projects/:id`; the frontend cut from `VITE_APP_NAME` to runtime config, +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//` URL is public; the `/p//` route prefix +with the generic `/e/` entry segment (§22.10) and the 308 redirects off +the old corpus-root URLs (`/rfc/` → `/p//e/`); `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 diff --git a/docs/design/multi-project.md b/docs/design/multi-project.md index 74cafab..7fb917f 100644 --- a/docs/design/multi-project.md +++ b/docs/design/multi-project.md @@ -269,8 +269,10 @@ Two chrome layers result: ## 5. Routing & UX -- RFC routes gain a project prefix: `/p//rfc/`, - `/p//proposals/`, `/p//philosophy`, etc. +- Entry routes gain a project prefix with a **generic `/e/` segment**: + `/p//e/`, `/p//proposals/`, + `/p//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. @@ -293,10 +295,13 @@ 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`). + 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/` URLs alive - (308 → `/p//rfc/`). + (308 → `/p//e/`). This is the SPEC §20 upgrade-steps block. @@ -346,10 +351,15 @@ This confirms the registry decision and fixes how it integrates: `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.** Slug-only identity means no `RFC-NNNN`. Is - the route segment generic (`/p//e/`) with the displayed noun - type-driven, or does the segment itself vary by type? (Leaning: generic - path segment, type-driven label.) -- **Existing graduated numbers.** OHM already has `RFC-0001…` in filenames. - On dropping numeric identity, do those numbers survive as a display/legacy - artifact, or get normalized to slugs? (Migration/back-compat detail.) +- **Entry-noun in URLs/labels** — *resolved 2026-06-02:* generic route + segment `/p//e/` 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). From 503689bf1abc124932c6f984bf33270ce37ad287 Mon Sep 17 00:00:00 2001 From: Ben Stull Date: Tue, 2 Jun 2026 20:02:34 -0700 Subject: [PATCH 5/5] =?UTF-8?q?feat(projects):=20M2=20=E2=80=94=20project-?= =?UTF-8?q?scoped=20authorization=20+=20the=20=C2=A722.7=20resolver=20(?= =?UTF-8?q?=C2=A722)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- backend/app/api.py | 41 ++- backend/app/api_branches.py | 98 +++-- backend/app/api_contributions.py | 23 +- backend/app/api_discussion.py | 25 +- backend/app/api_graduation.py | 23 +- backend/app/api_invitations.py | 15 +- backend/app/api_notifications.py | 4 +- backend/app/api_prs.py | 31 +- backend/app/auth.py | 217 ++++++++++- .../test_multi_project_authz_vertical.py | 347 ++++++++++++++++++ docs/design/multi-project-spec.md | 33 +- 11 files changed, 742 insertions(+), 115 deletions(-) create mode 100644 backend/tests/test_multi_project_authz_vertical.py diff --git a/backend/app/api.py b/backend/app/api.py index 0d77109..1b9e51d 100644 --- a/backend/app/api.py +++ b/backend/app/api.py @@ -617,15 +617,23 @@ def make_router( """ viewer = auth.current_user(request) viewer_id = viewer.user_id if viewer else None + # §22.5: a gated project's entries never surface in a non-member's + # catalog. For the single public default project this is the full set. + visible = auth.visible_project_ids(viewer) + if not visible: + return {"items": []} + placeholders = ",".join("?" for _ in visible) rows = db.conn().execute( - """ + f""" SELECT slug, title, state, rfc_id, repo, owners_json, arbiters_json, tags_json, last_main_commit_at, last_entry_commit_at, updated_at FROM cached_rfcs WHERE state IN ('super-draft', 'active') + AND project_id IN ({placeholders}) ORDER BY COALESCE(last_main_commit_at, last_entry_commit_at) DESC - """ + """, + visible, ).fetchall() starred = set() @@ -657,12 +665,14 @@ def make_router( return {"items": items} @router.get("/api/rfcs/{slug}") - async def get_rfc(slug: str) -> dict[str, Any]: + async def get_rfc(slug: str, request: Request) -> dict[str, Any]: row = db.conn().execute( "SELECT * FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() if row is None: raise HTTPException(404, "Not found") + # §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members. + auth.require_project_readable(auth.current_user(request), row["project_id"]) payload = _serialize_rfc(row) # Roadmap #26: surface the optional propose-time use case on the # RFC view. The idea PR closes on merge, but the canonical row in @@ -694,14 +704,21 @@ def make_router( return row["use_case"] if row else None @router.get("/api/proposals") - async def list_proposals() -> dict[str, Any]: + async def list_proposals(request: Request) -> dict[str, Any]: + # §22.5: idea PRs in a gated project never surface to non-members. + visible = auth.visible_project_ids(auth.current_user(request)) + if not visible: + return {"items": []} + placeholders = ",".join("?" for _ in visible) rows = db.conn().execute( - """ + f""" SELECT rfc_slug, pr_number, title, description, opened_by, opened_at, state FROM cached_prs WHERE pr_kind = 'idea' AND state = 'open' + AND project_id IN ({placeholders}) ORDER BY opened_at DESC - """ + """, + visible, ).fetchall() return { "items": [ @@ -736,6 +753,8 @@ def make_router( ).fetchone() if row is None: raise HTTPException(404, "Not a proposal PR") + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + auth.require_project_readable(auth.current_user(request), row["project_id"]) # Read the proposed entry file from the head branch. slug = row["rfc_slug"] head = row["head_branch"] @@ -771,6 +790,12 @@ def make_router( @router.post("/api/rfcs/propose") async def propose_rfc(payload: ProposeBody, request: Request) -> dict[str, Any]: user = auth.require_contributor(request) + # §22.6/§22.7: proposing a new entry requires project-level contribute + # standing. Through M2 every entry lands in the default project; M3's + # routing carries the target project. On the public default project the + # implicit-public baseline preserves the pre-multi-project flow. + if not auth.can_contribute_in_project(user, auth.DEFAULT_PROJECT_ID): + raise HTTPException(403, "You do not have contribute access to this project") slug = payload.slug.strip().lower() if not entry_mod.is_valid_slug(slug): raise HTTPException(422, "Slug must be lowercase letters, digits, and dashes") @@ -995,10 +1020,12 @@ def make_router( async def add_funder_consent(slug: str, request: Request) -> dict[str, Any]: user = auth.require_contributor(request) rfc = db.conn().execute( - "SELECT 1 FROM cached_rfcs WHERE slug = ?", (slug,) + "SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() if rfc is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + auth.require_project_readable(user, rfc["project_id"]) # §6.7: refuse consent from a user with no registered credentials # — a consent without a universe would be inert and the surface # should fail loudly rather than silently. diff --git a/backend/app/api_branches.py b/backend/app/api_branches.py index 9218c0b..ebd1e2a 100644 --- a/backend/app/api_branches.py +++ b/backend/app/api_branches.py @@ -120,7 +120,9 @@ def make_router( # ------------------------------------------------------------------- @router.get("/api/rfcs/{slug}/models") - async def list_models_for_rfc(slug: str) -> dict[str, Any]: + async def list_models_for_rfc(slug: str, request: Request) -> dict[str, Any]: + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + _require_rfc(slug, auth.current_user(request)) resolved = models_resolver.resolve_models_for_rfc(slug, providers) return { "models": [ @@ -140,7 +142,7 @@ def make_router( @router.get("/api/rfcs/{slug}/main") async def get_rfc_main(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_rfc(slug) + rfc = _require_rfc(slug, viewer) if rfc["state"] not in ("active", "super-draft"): raise HTTPException(409, f"RFC is {rfc['state']}") @@ -289,7 +291,7 @@ def make_router( 403, "This RFC's owner has not invited you to contribute PRs", ) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) owner, repo = _repo_for(rfc) new_branch = (body.branch_name or "").strip() if not new_branch: @@ -363,7 +365,7 @@ def make_router( 403, "This RFC's owner has not invited you to contribute PRs", ) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) owner, repo = _repo_for(rfc) new_branch = (body.branch_name or "").strip() if not new_branch: @@ -408,7 +410,7 @@ def make_router( @router.post("/api/rfcs/{slug}/metadata") async def edit_metadata(slug: str, body: MetadataEditBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) # Permission: super-draft owners/arbiters per §6.3, plus app-wide # admins/owners per §6.1. Until claim, that collapses to admin/owner. if not _can_edit_metadata(rfc, viewer): @@ -481,7 +483,7 @@ def make_router( request: Request, ) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) _require_can_contribute(slug, branch, viewer) row = _require_pending_change(slug, branch, change_id) if row["kind"] != "ai": @@ -568,7 +570,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/decline") async def decline_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) _require_can_contribute(slug, branch, viewer) row = _require_pending_change(slug, branch, change_id) if row["kind"] != "ai": @@ -586,7 +588,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/changes/{change_id}/reask") async def reask_change(slug: str, branch: str, change_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) _require_can_contribute(slug, branch, viewer) row = _require_change(slug, branch, change_id) if row["kind"] != "ai": @@ -653,7 +655,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/manual-flush") async def manual_flush(slug: str, branch: str, body: ManualFlushBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) _require_can_contribute(slug, branch, viewer) owner, repo = _repo_for(rfc, branch) path = _file_path_for(rfc, branch) @@ -730,7 +732,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/visibility") async def set_branch_visibility(slug: str, branch: str, body: VisibilityBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) creator = _branch_creator(slug, branch) _require_branch_owner(rfc, viewer, creator) current = _branch_vis(slug, branch) @@ -751,7 +753,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/grants") async def add_branch_grant(slug: str, branch: str, body: GrantBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) creator = _branch_creator(slug, branch) _require_branch_owner(rfc, viewer, creator) grantee = db.conn().execute( @@ -772,7 +774,7 @@ def make_router( @router.delete("/api/rfcs/{slug}/branches/{branch:path}/grants/{grantee_login}") async def revoke_branch_grant(slug: str, branch: str, grantee_login: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) creator = _branch_creator(slug, branch) _require_branch_owner(rfc, viewer, creator) grantee = db.conn().execute( @@ -792,7 +794,7 @@ def make_router( @router.get("/api/rfcs/{slug}/branches/{branch:path}/threads") async def list_branch_threads(slug: str, branch: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") rows = db.conn().execute( @@ -810,7 +812,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/threads") async def create_branch_thread(slug: str, branch: str, body: ThreadCreateBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) if body.thread_kind == "flag" and not body.label: raise HTTPException(422, "Flag threads require a label") cur = db.conn().execute( @@ -840,7 +842,7 @@ def make_router( @router.get("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/messages") async def get_thread_messages(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") thread = _require_thread(slug, branch, thread_id) @@ -865,7 +867,7 @@ def make_router( slug: str, branch: str, thread_id: int, body: ThreadMessageBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) _require_thread(slug, branch, thread_id) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") @@ -886,7 +888,7 @@ def make_router( to this (slug, branch) on or before the new cursor is marked read. """ viewer = auth.require_user(request) - _require_rfc_with_repo(slug) + _require_rfc_with_repo(slug, viewer) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") last_seen = int(body.get("last_seen_message_id") or 0) or None @@ -909,7 +911,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/threads/{thread_id}/resolve") async def resolve_thread(slug: str, branch: str, thread_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) thread = _require_thread(slug, branch, thread_id) creator = _branch_creator(slug, branch) if not _can_resolve_thread(rfc, thread, creator, viewer): @@ -932,7 +934,7 @@ def make_router( slug: str, branch: str, thread_id: int, body: ChatTurnBody, request: Request ): viewer = auth.require_contributor(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) thread = _require_thread(slug, branch, thread_id) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") @@ -1018,7 +1020,7 @@ def make_router( @router.get("/api/rfcs/{slug}/branches/{branch:path}") async def get_branch_view(slug: str, branch: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_rfc_with_repo(slug) + rfc = _require_rfc_with_repo(slug, viewer) if not _can_read_branch(slug, branch, viewer): raise HTTPException(403, "Branch is private") @@ -1089,31 +1091,34 @@ def make_router( # Permission + state helpers (closures, share `config` etc.) # ------------------------------------------------------------------ - def _require_rfc(slug: str): + def _require_rfc(slug: str, viewer): row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if row is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive, §22.7): a gated project's entries + # 404 to non-members — indistinguishable from an unknown slug. + auth.require_project_readable(viewer, row["project_id"]) return row - def _require_rfc_with_repo(slug: str): + def _require_rfc_with_repo(slug: str, viewer): """Used by every branch-scoped endpoint. Under the meta-only topology (§1) the meta repo is the implicit target for every entry — super-draft and active alike — so there is no per-RFC repo check. The name is retained for call-site stability; a withdrawn entry is still rejected.""" - row = _require_rfc(slug) + row = _require_rfc(slug, viewer) if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") return row - def _require_active_rfc(slug: str): - row = _require_rfc_with_repo(slug) + def _require_active_rfc(slug: str, viewer): + row = _require_rfc_with_repo(slug, viewer) if row["state"] != "active": raise HTTPException(409, f"RFC is {row['state']}, not active") return row - def _require_super_draft(slug: str): - row = _require_rfc(slug) + def _require_super_draft(slug: str, viewer): + row = _require_rfc(slug, viewer) if row["state"] != "super-draft": raise HTTPException(409, f"RFC is {row['state']}, not super-draft") return row @@ -1259,6 +1264,12 @@ def make_router( return row["on_behalf_of"] if row else None def _can_read_branch(slug: str, branch: str, viewer) -> bool: + # §22.5 visibility gate first (subtractive, §22.7): in a gated project + # nothing — not even main or a read_public branch — is readable by a + # non-member. + pid = auth.project_of_rfc(slug) + if not auth.can_read_project(viewer, pid): + return False if branch == "main": return True vis = _branch_vis(slug, branch) @@ -1266,7 +1277,7 @@ def make_router( return True if viewer is None: return False - if viewer.role in ("owner", "admin"): + if auth.is_project_superuser(viewer, pid): return True creator = _branch_creator(slug, branch) if creator and viewer.gitea_login == creator: @@ -1299,7 +1310,12 @@ def make_router( # legacy `repo:` is set (nothing, after the RFC-0001 fold-back). if rfc["state"] == "active" and rfc["repo"] and _is_meta_branch_name(branch): return False - if viewer.role in ("owner", "admin"): + pid = auth.project_of_rfc(slug) + # §22.5 visibility gate (subtractive): no contribute in an unreadable + # project. + if not auth.can_read_project(viewer, pid): + return False + if auth.is_project_superuser(viewer, pid): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1310,7 +1326,10 @@ def make_router( return True vis = _branch_vis(slug, branch) if vis["contribute_mode"] == "any-contributor": - return True + # "any contributor" means anyone with project-level write standing + # (§22.6/§22.7) — the implicit-public baseline on a public project, + # or an explicit project_contributor/admin elsewhere. + return auth.can_contribute_in_project(viewer, pid) if vis["contribute_mode"] == "specific": row = db.conn().execute( """ @@ -1331,7 +1350,9 @@ def make_router( raise HTTPException(403, "You do not have contribute access to this branch") def _require_branch_owner(rfc, viewer, creator: str | None) -> None: - if viewer.role in ("owner", "admin"): + # §22.6: a project_admin is the per-RFC owner/arbiter authority lifted + # to project scope, so it (and a deployment owner/admin) clears here. + if auth.is_project_superuser(viewer, rfc["project_id"]): return owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1342,11 +1363,12 @@ def make_router( raise HTTPException(403, "Only the branch creator, an RFC owner/arbiter, or an admin/owner may change branch settings") def _can_edit_metadata(rfc, viewer) -> bool: - """§9.5: super-draft owners/arbiters per §6.3 plus app admins/owners. - Until §13.1's claim runs, the super-draft has no owners, so the set - collapses to app admins/owners only — sensible because admin oversight - is the only path to canonicalizing edits on an unclaimed entry.""" - if viewer.role in ("owner", "admin"): + """§9.5: super-draft owners/arbiters per §6.3 plus project_admin / + app admins/owners (§22.6). Until §13.1's claim runs, the super-draft + has no owners, so the set collapses to the superuser tier only — + sensible because admin oversight is the only path to canonicalizing + edits on an unclaimed entry.""" + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") @@ -1359,7 +1381,7 @@ def make_router( "can_read": _can_read_branch(slug, branch, viewer), "can_contribute": _can_contribute(rfc, slug, branch, viewer) if viewer else False, "can_change_branch_settings": viewer is not None and ( - viewer.role in ("owner", "admin") + auth.is_project_superuser(viewer, rfc["project_id"]) or (creator is not None and viewer.gitea_login == creator) or viewer.gitea_login in (owners + arbiters) ), @@ -1405,7 +1427,7 @@ def make_router( def _can_resolve_thread(rfc, thread, creator: str | None, viewer) -> bool: if viewer is None: return False - if viewer.role in ("owner", "admin"): + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_contributions.py b/backend/app/api_contributions.py index e9f4fb8..5a4c0b8 100644 --- a/backend/app/api_contributions.py +++ b/backend/app/api_contributions.py @@ -55,16 +55,19 @@ class ContributionRequestBody(BaseModel): use_case: str | None = Field(default=None, max_length=_USE_CASE_MAX) -def _require_super_draft(slug: str): +def _require_super_draft(slug: str, viewer): """The contribute surface only operates on a *pending* RFC. 404 on unknown; 409 on a state that isn't a super-draft (active RFCs use the - Part-1 link, not a contribute offer; withdrawn is closed).""" + Part-1 link, not a contribute offer; withdrawn is closed). The §22.5 + visibility gate is subtractive: a gated project's entries 404 to + non-members (§22.7).""" row = db.conn().execute( - "SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?", + "SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() if row is None: raise HTTPException(404, "RFC not found") + auth.require_project_readable(viewer, row["project_id"]) if row["state"] != "super-draft": raise HTTPException(409, "RFC is not a pending super-draft") return row @@ -88,7 +91,7 @@ def _viewer_relationship(viewer, slug: str) -> str | None: """Why this viewer can't *request* to contribute — or None if they can. Owners/admins already have the RFC; existing collaborators are already in. Both get a clear 409 rather than a useless self-request.""" - if auth.is_rfc_owner(viewer, slug) or viewer.role in ("owner", "admin"): + if auth.is_rfc_owner(viewer, slug) or auth.is_project_superuser(viewer, auth.project_of_rfc(slug)): return "You already own or administer this RFC." if auth.is_rfc_collaborator(viewer, slug): return "You're already a collaborator on this RFC." @@ -105,16 +108,18 @@ def make_router() -> APIRouter: @router.get("/api/rfcs/{slug}/contribution-target") async def contribution_target(slug: str, request: Request) -> dict[str, Any]: row = db.conn().execute( - "SELECT slug, title, state, owners_json, proposed_by FROM cached_rfcs WHERE slug = ?", + "SELECT slug, title, state, owners_json, proposed_by, project_id FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() if row is None: raise HTTPException(404, "RFC not found") + viewer = auth.current_user(request) + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + auth.require_project_readable(viewer, row["project_id"]) from . import rfc_links # local import: avoid a module import cycle owner = rfc_links._owner_display(db.conn(), row["owners_json"], row["proposed_by"]) - viewer = auth.current_user(request) eligible = True reason: str | None = None @@ -159,7 +164,7 @@ def make_router() -> APIRouter: slug: str, body: ContributionRequestBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_super_draft(slug) + _require_super_draft(slug, viewer) reason = _viewer_relationship(viewer, slug) if reason is not None: @@ -214,7 +219,7 @@ def make_router() -> APIRouter: slug: str, request_id: int, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException(403, "Only the RFC's owner can act on contribution requests") @@ -282,7 +287,7 @@ def make_router() -> APIRouter: slug: str, request_id: int, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_super_draft(slug) + _require_super_draft(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException(403, "Only the RFC's owner can act on contribution requests") diff --git a/backend/app/api_discussion.py b/backend/app/api_discussion.py index 8153096..25956b5 100644 --- a/backend/app/api_discussion.py +++ b/backend/app/api_discussion.py @@ -84,7 +84,7 @@ def make_router() -> APIRouter: @router.get("/api/rfcs/{slug}/discussion/threads") async def list_discussion_threads(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - _require_rfc_readable(slug) + _require_rfc_readable(slug, viewer) # Ensure the default whole-doc discussion thread exists. We mint # it on first read regardless of viewer (anonymous viewers can # trigger the creation — the row's `created_by` is null in that @@ -115,7 +115,7 @@ def make_router() -> APIRouter: slug: str, body: DiscussionThreadCreateBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_readable(slug) + _require_rfc_readable(slug, viewer) # v0.16.0 (roadmap item #12): the per-RFC discussion is now a # gated surface. The platform-level `require_contributor` above # ensures the user is signed in + admin-granted; this layer @@ -155,8 +155,8 @@ def make_router() -> APIRouter: async def get_discussion_thread_messages( slug: str, thread_id: int, request: Request ) -> dict[str, Any]: - _viewer = auth.current_user(request) - _require_rfc_readable(slug) + viewer = auth.current_user(request) + _require_rfc_readable(slug, viewer) thread = _require_discussion_thread(slug, thread_id) rows = db.conn().execute( """ @@ -193,7 +193,7 @@ def make_router() -> APIRouter: slug: str, thread_id: int, body: DiscussionMessageBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc_readable(slug) + _require_rfc_readable(slug, viewer) # v0.16.0 (item #12): same per-RFC gate as create_discussion_thread. if not auth.can_discuss_rfc(viewer, slug): raise HTTPException( @@ -218,7 +218,7 @@ def make_router() -> APIRouter: slug: str, thread_id: int, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc_readable(slug) + rfc = _require_rfc_readable(slug, viewer) thread = _require_discussion_thread(slug, thread_id) if not _can_resolve(rfc, thread, viewer): raise HTTPException( @@ -245,15 +245,18 @@ def make_router() -> APIRouter: # --------------------------------------------------------------------------- -def _require_rfc_readable(slug: str): - """Per the v0.3.0 anonymous-read contract: any cached RFC is readable - by anyone. Withdrawn entries refuse reads of every shape — same rule - `_require_rfc_with_repo` in `api_branches.py` follows.""" +def _require_rfc_readable(slug: str, viewer): + """Per the v0.3.0 anonymous-read contract: any cached RFC in a *readable* + project is readable by anyone. The §22.5 visibility gate is subtractive on + top (§22.7): a gated project's entries 404 to non-members. Withdrawn + entries refuse reads of every shape — same rule `_require_rfc_with_repo` + in `api_branches.py` follows.""" row = db.conn().execute( "SELECT * FROM cached_rfcs WHERE slug = ?", (slug,) ).fetchone() if row is None: raise HTTPException(404, "RFC not found") + auth.require_project_readable(viewer, row["project_id"]) if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") return row @@ -305,7 +308,7 @@ def _ensure_discussion_thread(slug: str, viewer) -> int: def _can_resolve(rfc, thread, viewer) -> bool: if viewer is None: return False - if viewer.role in ("owner", "admin"): + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_graduation.py b/backend/app/api_graduation.py index 6933145..da753f3 100644 --- a/backend/app/api_graduation.py +++ b/backend/app/api_graduation.py @@ -191,7 +191,7 @@ def make_router( @router.get("/api/rfcs/{slug}/blocking-prs") async def list_blocking_prs(slug: str, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) rows = db.conn().execute( """ SELECT pr_number, title, opened_by, opened_at, head_branch, pr_kind @@ -210,7 +210,7 @@ def make_router( can_merge = ( viewer is not None and ( - viewer.role in ("owner", "admin") + auth.is_project_superuser(viewer, rfc["project_id"]) or viewer.gitea_login in owners or viewer.gitea_login in arbiters ) @@ -250,7 +250,7 @@ def make_router( slug: str, request: Request, ) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) del viewer # no permission gate — the dialog only shows up for # admins/owners, but the check itself is read-only. @@ -300,7 +300,7 @@ def make_router( @router.post("/api/rfcs/{slug}/graduate") async def graduate(slug: str, body: GraduateBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) # §13: only owners/arbiters of the RFC and app admins/owners may # graduate. Until §13.1's claim runs the entry has no owners, so # the set collapses to app admins/owners for unclaimed entries. @@ -441,7 +441,13 @@ def make_router( @router.post("/api/rfcs/{slug}/claim") async def claim_ownership(slug: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_super_draft(slug) + rfc = _require_super_draft(slug, viewer) + # §22.6/§22.7: claiming an unclaimed super-draft is a contribute action. + # On a public project this is the pre-M2 baseline (an unclaimed entry + # has no owners, so any granted contributor qualifies); on a gated + # project it requires a project_contributor/admin grant. + if not auth.can_contribute_to_rfc(viewer, slug): + raise HTTPException(403, "You do not have contribute access to this project") existing_owners = json.loads(rfc["owners_json"] or "[]") if viewer.gitea_login in existing_owners: return {"ok": True, "noop": True} @@ -486,10 +492,12 @@ def make_router( # Helpers # ------------------------------------------------------------------- - def _require_super_draft(slug: str): + def _require_super_draft(slug: str, viewer): row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if row is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive, §22.7): gated → 404 to non-members. + auth.require_project_readable(viewer, row["project_id"]) if row["state"] != "super-draft": raise HTTPException(409, f"RFC is {row['state']}, not super-draft") return row @@ -666,7 +674,8 @@ async def _finish_failed(state: GraduationState, *, failed_at: str, on_behalf_of def _can_graduate(rfc, viewer) -> bool: if viewer is None: return False - if viewer.role in ("owner", "admin"): + # §6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters. + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/api_invitations.py b/backend/app/api_invitations.py index d6e3d1f..b8440b1 100644 --- a/backend/app/api_invitations.py +++ b/backend/app/api_invitations.py @@ -126,7 +126,7 @@ def make_router() -> APIRouter: @router.post("/api/rfcs/{slug}/invitations") async def create_invitation(slug: str, body: CreateInvitationBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_rfc(slug) + rfc = _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, @@ -151,7 +151,7 @@ def make_router() -> APIRouter: @router.get("/api/rfcs/{slug}/invitations") async def list_invitations(slug: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc(slug) + _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, @@ -207,7 +207,7 @@ def make_router() -> APIRouter: @router.post("/api/rfcs/{slug}/invitations/{invitation_id}/revoke") async def revoke_invitation(slug: str, invitation_id: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_rfc(slug) + _require_rfc(slug, viewer) if not auth.can_invite_to_rfc(viewer, slug): raise HTTPException( 403, @@ -373,15 +373,18 @@ def make_router() -> APIRouter: # --------------------------------------------------------------------------- -def _require_rfc(slug: str): +def _require_rfc(slug: str, viewer): """The invitation surface only operates on a known, non-withdrawn RFC. We refuse 404 on unknown and 409 on withdrawn — mirrors the - discussion endpoints' `_require_rfc_readable` shape.""" + discussion endpoints' `_require_rfc_readable` shape. The §22.5 + visibility gate is subtractive: a gated project's entries 404 to + non-members (§22.7).""" row = db.conn().execute( - "SELECT slug, title, state FROM cached_rfcs WHERE slug = ?", (slug,), + "SELECT slug, title, state, project_id FROM cached_rfcs WHERE slug = ?", (slug,), ).fetchone() if row is None: raise HTTPException(404, "RFC not found") + auth.require_project_readable(viewer, row["project_id"]) if row["state"] == "withdrawn": raise HTTPException(409, "RFC is withdrawn") return row diff --git a/backend/app/api_notifications.py b/backend/app/api_notifications.py index 01f6adc..34320af 100644 --- a/backend/app/api_notifications.py +++ b/backend/app/api_notifications.py @@ -213,9 +213,11 @@ def make_router(config: Config) -> APIRouter: @router.post("/api/rfcs/{slug}/watch") async def set_watch(slug: str, body: WatchBody, request: Request) -> dict[str, Any]: viewer = auth.require_user(request) - rfc = db.conn().execute("SELECT slug FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() + rfc = db.conn().execute("SELECT project_id FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if rfc is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive): gated → 404 to non-members. + auth.require_project_readable(viewer, rfc["project_id"]) db.conn().execute( """ INSERT INTO watches (user_id, rfc_slug, state, set_by, set_at, last_participation_at) diff --git a/backend/app/api_prs.py b/backend/app/api_prs.py index 69630c8..753675e 100644 --- a/backend/app/api_prs.py +++ b/backend/app/api_prs.py @@ -85,7 +85,7 @@ def make_router( @router.post("/api/rfcs/{slug}/branches/{branch:path}/pr-draft") async def draft_pr_text(slug: str, branch: str, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) owner, repo = _owner_repo(rfc) path = _file_path_for(rfc) if not _branch_has_commits_ahead(slug, branch): @@ -128,7 +128,7 @@ def make_router( 403, "This RFC's owner has not invited you to contribute PRs", ) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) if branch == "main": raise HTTPException(409, "PRs open from non-main branches") owner, repo = _owner_repo(rfc) @@ -207,7 +207,7 @@ def make_router( @router.get("/api/rfcs/{slug}/prs/{pr_number}") async def get_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: viewer = auth.current_user(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) owner, repo = _owner_repo(rfc) path = _file_path_for(rfc) @@ -373,7 +373,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/seen") async def advance_seen(slug: str, pr_number: int, body: PRSeenBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_active_rfc(slug) + _require_active_rfc(slug, viewer) _require_pr(slug, pr_number) # Take the max of stored and incoming for both cursors so a # stale tab firing a seen-cursor advance after a fresher tab @@ -420,7 +420,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/review") async def post_review_thread(slug: str, pr_number: int, body: PRReviewBody, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - _require_active_rfc(slug) + _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) head_branch = pr_row["head_branch"] cur = db.conn().execute( @@ -447,7 +447,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/merge") async def merge_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) if not _can_merge(rfc, viewer): raise HTTPException(403, "Only arbiters, RFC owners, and app admins/owners may merge") @@ -479,7 +479,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/withdraw") async def withdraw_pr(slug: str, pr_number: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) if not _can_withdraw(rfc, pr_row, viewer): raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or app admin/owner) may withdraw") @@ -510,7 +510,7 @@ def make_router( slug: str, pr_number: int, body: PRDescriptionBody, request: Request ) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) if not _can_edit_pr_text(rfc, pr_row, viewer): raise HTTPException(403, "Only the contributor or an RFC owner/arbiter (or admin/owner) may edit") @@ -535,7 +535,7 @@ def make_router( @router.post("/api/rfcs/{slug}/prs/{pr_number}/resolution-branch") async def start_resolution_branch(slug: str, pr_number: int, request: Request) -> dict[str, Any]: viewer = auth.require_contributor(request) - rfc = _require_active_rfc(slug) + rfc = _require_active_rfc(slug, viewer) pr_row = _require_pr(slug, pr_number) if pr_row["state"] != "open": raise HTTPException(409, f"PR is {pr_row['state']}, not open") @@ -661,20 +661,23 @@ def make_router( # Helpers (closures over config/gitea/etc.) # ------------------------------------------------------------------ - def _require_rfc(slug: str): + def _require_rfc(slug: str, viewer): row = db.conn().execute("SELECT * FROM cached_rfcs WHERE slug = ?", (slug,)).fetchone() if row is None: raise HTTPException(404, "RFC not found") + # §22.5 visibility gate (subtractive, §22.7) — even §11.3 "PRs always + # public" yields to a gated project: non-members get 404. + auth.require_project_readable(viewer, row["project_id"]) return row - def _require_active_rfc(slug: str): + def _require_active_rfc(slug: str, viewer): """Used by the §10 PR-flow read and write paths. Per §17's routing- collapse rule, a super-draft RFC also routes here — its body-edit PRs are meta-repo PRs with pr_kind='meta_body_edit', but the API surface is identical. Under the meta-only topology (§1) an active RFC is meta-resident too (repo is null) — that is normal, not an error, so there is no per-RFC-repo precondition.""" - row = _require_rfc(slug) + row = _require_rfc(slug, viewer) if row["state"] not in ("active", "super-draft"): raise HTTPException(409, f"RFC is {row['state']}") return row @@ -780,10 +783,10 @@ def make_router( def _can_merge(rfc, viewer) -> bool: - """§6.1 admin/owner OR §6.3 RFC owners/arbiters.""" + """§6.1 admin/owner or §22.6 project_admin OR §6.3 RFC owners/arbiters.""" if viewer is None: return False - if viewer.role in ("owner", "admin"): + if auth.is_project_superuser(viewer, rfc["project_id"]): return True owners = json.loads(rfc["owners_json"] or "[]") arbiters = json.loads(rfc["arbiters_json"] or "[]") diff --git a/backend/app/auth.py b/backend/app/auth.py index c554ac1..f93438e 100644 --- a/backend/app/auth.py +++ b/backend/app/auth.py @@ -19,6 +19,7 @@ from fastapi import HTTPException, Request from . import db from .bot import Actor from .config import Config +from .projects import DEFAULT_PROJECT_ID @dataclass @@ -290,6 +291,163 @@ def require_admin(request: Request) -> SessionUser: return user +# =========================================================================== +# §22.6 / §22.7 — project-scoped authorization (the multi-project middle tier). +# +# A deployment hosts N projects (§22). Authorization for an action on an RFC is +# the *most-permissive union* of three tiers — the actor's deployment role +# (§6.1), their project role (§22.6), and their per-RFC authority (§6.3/§12) — +# with the §22.5 visibility gate and the §6.2 write-mute *subtractive* on top +# (§22.7). The per-RFC capability helpers below (`can_discuss_rfc`, +# `can_contribute_to_rfc`, `can_invite_to_rfc`) compose all three tiers, so the +# ~20 endpoint call sites inherit multi-project behavior unchanged. +# +# Through Slice M2 the only project is the migration-seeded `default` one (the +# N=1 case, §22.13); a project is resolved from an RFC slug via +# `cached_rfcs.project_id` (unique per slug while N=1). M3's registry mirror +# lets a deployment declare a second project; these gates already hold then. +# +# OPERATOR DECISIONS (M2): +# * implicit-on-public — on a `public` project a granted deployment +# `contributor` keeps the pre-multi-project write *baseline* (propose +# freely; an owned RFC's discuss/contribute is still gated by the v0.16.0 +# per-RFC invite). No project_members row is needed and no backfill runs, +# so the N=1 case stays whole. Explicit project_members rows and +# gated/unlisted visibility are where the new tier actually bites. +# * preserve curation — the implicit-public baseline does NOT override per-RFC +# owner curation; only an *explicit* project_contributor/project_admin grant +# (or a deployment owner/admin) bypasses it. So §22.7's "project_contributor +# ⊇ rfc_collaborators(contributor)" holds for explicit grants, while a plain +# granted contributor on public behaves exactly as it did before M2. +# =========================================================================== + +_DEPLOYMENT_SUPERUSER_ROLES = ("owner", "admin") + + +def project_visibility(project_id: str) -> str: + """The project's §22.5 visibility ('gated' | 'public' | 'unlisted'). A + missing row reads as 'gated' — the safe default: an unknown project is + invisible rather than open.""" + row = db.conn().execute( + "SELECT visibility FROM projects WHERE id = ?", (project_id,) + ).fetchone() + if row is None: + return "gated" + return row["visibility"] or "gated" + + +def project_member_role(user: SessionUser | None, project_id: str) -> str | None: + """The user's *explicit* §22.6 project_members role in this project, or + None. This is the stored row only — it does not fold in the deployment tier + or the implicit-on-public baseline (those live in the helpers below).""" + if user is None: + return None + row = db.conn().execute( + "SELECT role FROM project_members WHERE project_id = ? AND user_id = ?", + (project_id, user.user_id), + ).fetchone() + return row["role"] if row else None + + +def project_of_rfc(rfc_slug: str) -> str: + """The project an RFC belongs to (`cached_rfcs.project_id`). Falls back to + the default project when the slug isn't cached or the column is unset — the + same N=1 default migration 025 backfills.""" + row = db.conn().execute( + "SELECT project_id FROM cached_rfcs WHERE slug = ?", (rfc_slug,) + ).fetchone() + if row is None: + return DEFAULT_PROJECT_ID + return row["project_id"] or DEFAULT_PROJECT_ID + + +def is_project_superuser(user: SessionUser | None, project_id: str) -> bool: + """Maximal authority within a project: a deployment owner/admin (superuser + in every project, §22.7) or an explicit `project_admin` (§22.6). Both + subsume the per-RFC owners/arbiters tier.""" + if user is None: + return False + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return True + return project_member_role(user, project_id) == "project_admin" + + +def can_read_project(user: SessionUser | None, project_id: str) -> bool: + """The §22.5 visibility gate. `public`/`unlisted` are readable by anyone + (anonymous included — `unlisted` is link-only but the link still reads); + `gated` is readable only by a deployment owner/admin or a granted project + member of any role. Used as the subtractive read gate (a gated project's + entries 404 to non-members).""" + vis = project_visibility(project_id) + if vis in ("public", "unlisted"): + return True + # gated — members + superusers only, subject to the §6 admission floor. + if user is None or user.permission_state != "granted": + return False + if user.role in _DEPLOYMENT_SUPERUSER_ROLES: + return True + return project_member_role(user, project_id) is not None + + +def require_project_readable(user: SessionUser | None, project_id: str) -> None: + """Raise 404 when the project is not readable by this viewer (§22.5: a + gated project is invisible to non-members — indistinguishable from absent, + so the shape matches an unknown slug).""" + if not can_read_project(user, project_id): + raise HTTPException(status_code=404, detail="RFC not found") + + +def _has_write_baseline(user: SessionUser | None, project_id: str) -> bool: + """The implicit-on-public write baseline: a granted deployment + `contributor` on a `public` project carries the pre-multi-project + write standing (still subject to per-RFC curation). Deployment + owner/admin are handled by `is_project_superuser`; on gated/unlisted a + contributor has no baseline and needs an explicit project role.""" + if user is None or user.permission_state != "granted": + return False + return user.role == "contributor" and project_visibility(project_id) == "public" + + +def can_contribute_in_project(user: SessionUser | None, project_id: str) -> bool: + """May the user contribute *new* content to the project (propose an entry) + — the project-level (not RFC-specific) contribute standing. The union of + the override grants (superuser / explicit project_contributor) and the + implicit-public baseline, subject to the visibility gate.""" + if user is None or user.permission_state != "granted": + return False + if not can_read_project(user, project_id): + return False + if is_project_superuser(user, project_id): + return True + if project_member_role(user, project_id) == "project_contributor": + return True + return _has_write_baseline(user, project_id) + + +def can_discuss_in_project(user: SessionUser | None, project_id: str) -> bool: + """May the user participate in discussion in the project at all — the + project-level discuss standing (project_viewer ⊇ discussant, §22.7). + A superset of `can_contribute_in_project` (a contributor can discuss).""" + if user is None or user.permission_state != "granted": + return False + if not can_read_project(user, project_id): + return False + if is_project_superuser(user, project_id): + return True + if project_member_role(user, project_id) in ("project_viewer", "project_contributor"): + return True + return _has_write_baseline(user, project_id) + + +def visible_project_ids(user: SessionUser | None) -> list[str]: + """Project ids whose entries may surface in a listing for this viewer — the + §22.5 read gate applied to the catalog/idea lists. (The directory's + `unlisted`-omission and the per-project routing are M3 concerns; for the M2 + listing filter we include every project the viewer can read.)""" + rows = db.conn().execute("SELECT id FROM projects").fetchall() + return [r["id"] for r in rows if can_read_project(user, r["id"])] + + # v0.16.0 (roadmap item #12): per-RFC membership helpers. # # These don't replace `require_contributor` — they layer on top of it for @@ -386,18 +544,30 @@ def can_discuss_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - if user.role in ("owner", "admin"): + pid = project_of_rfc(rfc_slug) + # §22.5 visibility gate is subtractive (§22.7) — no capability in a project + # the viewer cannot even read. + if not can_read_project(user, pid): + return False + # §22.7 union, override grants first — these bypass per-RFC curation + # (project_viewer ⊇ discussant; project_admin / deployment superuser ⊇ all). + if is_project_superuser(user, pid): return True + if project_member_role(user, pid) in ("project_viewer", "project_contributor"): + return True + # per-RFC authority (union term). owners = _rfc_owners_set(rfc_slug) - if not owners: - # No owner to gate the invite-list — fall through to the - # platform-granted contract. The first §13.1 claim engages - # the gate; before that, anyone platform-granted can - # contribute (mirrors the v0.5.0 / v0.6.0 contract). - return True if user.gitea_login in owners: return True - return is_rfc_collaborator(user, rfc_slug, role_in_rfc=None) + if is_rfc_collaborator(user, rfc_slug, role_in_rfc=None): + return True + # implicit-public baseline (curation preserved): a granted deployment + # contributor on a public project may discuss only while the RFC is + # unclaimed. The first §13.1 claim engages the per-RFC gate, mirroring the + # pre-multi-project v0.16.0 contract. + if not owners and _has_write_baseline(user, pid): + return True + return False def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: @@ -419,16 +589,27 @@ def can_contribute_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - if user.role in ("owner", "admin"): + pid = project_of_rfc(rfc_slug) + if not can_read_project(user, pid): + return False + # §22.7 union, override grants first (project_contributor ⊇ + # rfc_collaborators(contributor); project_admin / superuser ⊇ all). + if is_project_superuser(user, pid): return True + if project_member_role(user, pid) == "project_contributor": + return True + # per-RFC authority (union term). A 'discussant' row is NOT sufficient — + # PRs are the higher-privilege surface. owners = _rfc_owners_set(rfc_slug) - if not owners: - # Same fall-through as can_discuss_rfc: until an owner exists, - # the gate is open. - return True if user.gitea_login in owners: return True - return is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor") + if is_rfc_collaborator(user, rfc_slug, role_in_rfc="contributor"): + return True + # implicit-public baseline (curation preserved): until an owner exists, a + # granted deployment contributor on a public project may contribute. + if not owners and _has_write_baseline(user, pid): + return True + return False def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: @@ -439,7 +620,13 @@ def can_invite_to_rfc(user: SessionUser | None, rfc_slug: str) -> bool: return False if user.permission_state != "granted": return False - if user.role in ("owner", "admin"): + pid = project_of_rfc(rfc_slug) + if not can_read_project(user, pid): + return False + # Deployment owner/admin or project_admin (§22.6) may invite; otherwise + # only the RFC's frontmatter owner. Per-RFC collaborators and the + # implicit-public baseline do not get the invite-others power. + if is_project_superuser(user, pid): return True return is_rfc_owner(user, rfc_slug) diff --git a/backend/tests/test_multi_project_authz_vertical.py b/backend/tests/test_multi_project_authz_vertical.py new file mode 100644 index 0000000..c9368a3 --- /dev/null +++ b/backend/tests/test_multi_project_authz_vertical.py @@ -0,0 +1,347 @@ +"""Slice M2 — project-scoped authorization + the §22.7 resolver. + +M1 laid the schema spine (the `projects` / `project_members` tables and the +`project_id` column on every slug-bearing table). M2 builds the resolver on top: +the most-permissive union of the deployment role (§6.1), the project role +(§22.6), and the per-RFC authority (§6.3/§12), with the §22.5 visibility gate +subtractive on top (§22.7). + +Two operator decisions are pinned here as executable expectations: + + * implicit-on-public — a granted deployment `contributor` keeps its + pre-multi-project write *baseline* on a `public` project (propose freely; + an owned RFC's discuss/contribute still needs the per-RFC invite) with no + project_members row. So the single public default project behaves exactly + as it did before M2 (verified across the rest of the suite, and the + `*_public_*` tests below). + * preserve curation — the implicit-public baseline does NOT override per-RFC + owner curation; only an *explicit* project_contributor/admin grant (or a + deployment owner/admin) bypasses it. + +Everything is exercised on the single `default` project by flipping its +visibility and granting/revoking `project_members` roles — the M2 slice is +verifiable without a second project (which arrives with M3's registry mirror). +""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from test_propose_vertical import ( # noqa: F401 — fixtures land via import + app_with_fake_gitea, + provision_user_row, + sign_in_as, + tmp_env, +) + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _su(user_id: int, login: str, role: str, *, state: str = "granted"): + """A SessionUser handle for direct resolver calls (the endpoint tests use + sign_in_as instead).""" + from app import auth + + return auth.SessionUser( + user_id=user_id, + gitea_id=user_id, + gitea_login=login, + display_name=login.capitalize(), + email=f"{login}@test", + avatar_url="", + role=role, + permission_state=state, + ) + + +def _set_visibility(project_id: str, visibility: str) -> None: + from app import db + + db.conn().execute( + "UPDATE projects SET visibility = ? WHERE id = ?", (visibility, project_id) + ) + + +def _add_member(project_id: str, user_id: int, role: str) -> None: + from app import db + + db.conn().execute( + "INSERT OR REPLACE INTO project_members (project_id, user_id, role) VALUES (?, ?, ?)", + (project_id, user_id, role), + ) + + +def _remove_member(project_id: str, user_id: int) -> None: + from app import db + + db.conn().execute( + "DELETE FROM project_members WHERE project_id = ? AND user_id = ?", + (project_id, user_id), + ) + + +def _seed_rfc(slug: str, *, state: str = "active", owners=None, project_id: str = "default") -> None: + """A minimal cached_rfcs row — enough for the authz gates (state, owners, + project_id). project_id defaults to 'default' via migration 025 but we set + it explicitly for clarity.""" + import json + + from app import db + + db.conn().execute( + """ + INSERT OR REPLACE INTO cached_rfcs + (slug, title, state, owners_json, arbiters_json, tags_json, project_id) + VALUES (?, ?, ?, ?, '[]', '[]', ?) + """, + (slug, slug.capitalize(), state, json.dumps(owners or []), project_id), + ) + + +# --------------------------------------------------------------------------- +# 1. The §22.7 resolver — tier composition +# --------------------------------------------------------------------------- + + +def test_resolver_public_project_tiers(app_with_fake_gitea): + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=1, login="alice", role="contributor") + provision_user_row(user_id=2, login="ben", role="owner") + contributor = _su(1, "alice", "contributor") + owner = _su(2, "ben", "owner") + + # default is public — read open to everyone incl. anonymous. + assert auth.can_read_project(None, "default") is True + assert auth.can_read_project(contributor, "default") is True + + # implicit-on-public: a granted deployment contributor carries the + # write baseline without a project_members row. + assert auth.can_contribute_in_project(contributor, "default") is True + assert auth.can_discuss_in_project(contributor, "default") is True + + # ... but it is NOT project_admin (curation/override authority). + assert auth.is_project_superuser(contributor, "default") is False + # a deployment owner/admin is a superuser in every project. + assert auth.is_project_superuser(owner, "default") is True + + # a pending contributor has no write standing (the §6 admission floor). + pending = _su(1, "alice", "contributor", state="pending") + assert auth.can_contribute_in_project(pending, "default") is False + + +def test_resolver_gated_project_requires_membership(app_with_fake_gitea): + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=1, login="alice", role="contributor") + provision_user_row(user_id=2, login="ben", role="owner") + _set_visibility("default", "gated") + contributor = _su(1, "alice", "contributor") + owner = _su(2, "ben", "owner") + + # gated: no membership → invisible and no standing. + assert auth.can_read_project(None, "default") is False + assert auth.can_read_project(contributor, "default") is False + assert auth.can_contribute_in_project(contributor, "default") is False + + # deployment owner is a superuser regardless of membership. + assert auth.can_read_project(owner, "default") is True + assert auth.is_project_superuser(owner, "default") is True + + # project_viewer → read + discuss, but not contribute. + _add_member("default", 1, "project_viewer") + assert auth.can_read_project(contributor, "default") is True + assert auth.can_discuss_in_project(contributor, "default") is True + assert auth.can_contribute_in_project(contributor, "default") is False + + # project_contributor → contribute. + _add_member("default", 1, "project_contributor") + assert auth.can_contribute_in_project(contributor, "default") is True + assert auth.is_project_superuser(contributor, "default") is False + + # project_admin → superuser within the project. + _add_member("default", 1, "project_admin") + assert auth.is_project_superuser(contributor, "default") is True + + +# --------------------------------------------------------------------------- +# 2. Public default unchanged — the regression floor (curation preserved) +# --------------------------------------------------------------------------- + + +def test_public_per_rfc_curation_preserved(app_with_fake_gitea): + """On the public default project a granted contributor still cannot + discuss an *owned* RFC without a per-RFC invite (the v0.16.0 contract); + but an unclaimed (no-owners) entry stays open. This is the implicit-public + baseline with curation preserved.""" + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=1, login="alice", role="contributor") + provision_user_row(user_id=2, login="bob", role="contributor") + bob = _su(2, "bob", "contributor") + + _seed_rfc("owned", state="active", owners=["alice"]) + assert auth.can_discuss_rfc(bob, "owned") is False + assert auth.can_contribute_to_rfc(bob, "owned") is False + + _seed_rfc("draft", state="super-draft", owners=[]) + assert auth.can_discuss_rfc(bob, "draft") is True + assert auth.can_contribute_to_rfc(bob, "draft") is True + + +def test_public_read_open_write_gated_endpoints(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="bob", role="contributor") + _seed_rfc("ohm", state="active", owners=["alice"]) + + # anonymous can read the entry on a public project. + r = client.get("/api/rfcs/ohm") + assert r.status_code == 200, r.text + + # anonymous cannot open a discussion thread (401, the §6 write floor). + r = client.post("/api/rfcs/ohm/discussion/threads", json={"message": "hi"}) + assert r.status_code == 401 + + +# --------------------------------------------------------------------------- +# 3. The §22.5 visibility gate — 404 to non-members on read +# --------------------------------------------------------------------------- + + +def test_gated_project_404s_non_members(app_with_fake_gitea): + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=1, login="alice", role="contributor") + provision_user_row(user_id=2, login="bob", role="contributor") + provision_user_row(user_id=3, login="ben", role="owner") + _seed_rfc("sekret", state="active", owners=["alice"]) + _set_visibility("default", "gated") + + # anonymous → 404 (indistinguishable from an unknown slug). + assert client.get("/api/rfcs/sekret").status_code == 404 + + # signed-in non-member → 404 on the entry and its discussion. + sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") + assert client.get("/api/rfcs/sekret").status_code == 404 + assert client.get("/api/rfcs/sekret/discussion/threads").status_code == 404 + # the gated entry never surfaces in the non-member's catalog. + assert client.get("/api/rfcs").json()["items"] == [] + + # a project_viewer member can read again. + _add_member("default", 2, "project_viewer") + assert client.get("/api/rfcs/sekret").status_code == 200 + assert [i["slug"] for i in client.get("/api/rfcs").json()["items"]] == ["sekret"] + + # a deployment owner can always read. + sign_in_as(client, user_id=3, gitea_login="ben", display_name="Ben", role="owner") + assert client.get("/api/rfcs/sekret").status_code == 200 + + +# --------------------------------------------------------------------------- +# 4. The contribution gate on a gated project (401/403) +# --------------------------------------------------------------------------- + + +def test_gated_propose_requires_project_contributor(app_with_fake_gitea): + """Propose checks project-level contribute standing before any Gitea + work, so the gate is observable as a 403 (gated, non-member) without + seeding the success path.""" + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="bob", role="contributor") + _set_visibility("default", "gated") + sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") + + body = {"slug": "newidea", "title": "New Idea", "pitch": "A pitch.", "tags": []} + assert client.post("/api/rfcs/propose", json=body).status_code == 403 + + # grant project_contributor — the project gate now passes (the request + # proceeds past the gate; we assert only that it is no longer 403). + _add_member("default", 2, "project_contributor") + assert client.post("/api/rfcs/propose", json=body).status_code != 403 + + +def test_gated_viewer_can_discuss_contributor_can_contribute(app_with_fake_gitea): + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app) as client: + provision_user_row(user_id=2, login="bob", role="contributor") + _seed_rfc("spec", state="super-draft", owners=[]) + _set_visibility("default", "gated") + bob = _su(2, "bob", "contributor") + + # non-member: discussion thread create → 404 (visibility, before the + # write gate even applies). + sign_in_as(client, user_id=2, gitea_login="bob", display_name="Bob", role="contributor") + assert client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}).status_code == 404 + + # project_viewer: can discuss (200) but cannot contribute (resolver). + _add_member("default", 2, "project_viewer") + r = client.post("/api/rfcs/spec/discussion/threads", json={"message": "q"}) + assert r.status_code == 200, r.text + assert auth.can_contribute_to_rfc(bob, "spec") is False + + # project_contributor: can contribute. + _add_member("default", 2, "project_contributor") + assert auth.can_contribute_to_rfc(bob, "spec") is True + + +# --------------------------------------------------------------------------- +# 5. Union of tiers + subtractive visibility gate +# --------------------------------------------------------------------------- + + +def test_per_rfc_authority_unions_then_yields_to_visibility(app_with_fake_gitea): + from app import auth + from test_propose_vertical import grant_rfc_collaborator + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=2, login="bob", role="contributor") + _seed_rfc("owned", state="active", owners=["alice"]) + bob = _su(2, "bob", "contributor") + + # public + per-RFC collaborator(contributor) → contribute (union term). + grant_rfc_collaborator(user_id=2, rfc_slug="owned", role_in_rfc="contributor") + assert auth.can_contribute_to_rfc(bob, "owned") is True + + # flip to gated: the §22.5 gate is subtractive — the per-RFC grant no + # longer suffices without project membership. + _set_visibility("default", "gated") + assert auth.can_contribute_to_rfc(bob, "owned") is False + + # restore read via project membership → the per-RFC union applies again. + _add_member("default", 2, "project_viewer") + assert auth.can_contribute_to_rfc(bob, "owned") is True + + +# --------------------------------------------------------------------------- +# 6. Revocation takes effect on the next call +# --------------------------------------------------------------------------- + + +def test_revoking_membership_revokes_access(app_with_fake_gitea): + from app import auth + + app, _ = app_with_fake_gitea + with TestClient(app): + provision_user_row(user_id=2, login="bob", role="contributor") + _set_visibility("default", "gated") + bob = _su(2, "bob", "contributor") + + _add_member("default", 2, "project_contributor") + assert auth.can_contribute_in_project(bob, "default") is True + + _remove_member("default", 2) + assert auth.can_read_project(bob, "default") is False + assert auth.can_contribute_in_project(bob, "default") is False diff --git a/docs/design/multi-project-spec.md b/docs/design/multi-project-spec.md index f1c1191..2493e1a 100644 --- a/docs/design/multi-project-spec.md +++ b/docs/design/multi-project-spec.md @@ -484,13 +484,32 @@ single project, with the spine underneath. Additive only: no table rebuilds project #2, enumerated in migration 025's header). This is the foundation everything after builds on. -**M2 — Project-scoped authorization + the §22.7 resolver.** 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; this slice -is verifiable by granting/revoking roles on the default project and asserting -the gates. +**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`